diff --git a/.github/workflows/check-code.yaml b/.github/workflows/check-code.yaml index 94b5f3c1..479f173d 100644 --- a/.github/workflows/check-code.yaml +++ b/.github/workflows/check-code.yaml @@ -83,6 +83,26 @@ jobs: - name: Check typing run: uv run mypy . + check-plugins-rendered: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.12'] + steps: + - uses: actions/checkout@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - name: Verify platform-integrations matches a fresh render of plugin-source + run: uv run python plugin-source/build_plugins.py check + ui-tests: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index f4713cb0..b2928336 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ dist .secrets event.json site/ +example-guidelines .codex diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41e5f18a..d26ae5ec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,3 +50,15 @@ repos: name: detect secrets args: ['--baseline', '.secrets.baseline'] exclude: package.lock.json + + # Plugin render-equality gate — fails if platform-integrations/ has drifted + # from plugin-source/. Runs whenever plugin-source/ or the rendered tree + # under platform-integrations/ has changed. + - repo: local + hooks: + - id: plugins-rendered + name: plugins-rendered + entry: uv run python plugin-source/build_plugins.py check + language: system + pass_filenames: false + files: ^(plugin-source/|platform-integrations/) diff --git a/justfile b/justfile index 915da28f..fc1aaeaf 100644 --- a/justfile +++ b/justfile @@ -78,3 +78,12 @@ codex-run: # Smoke-test that Codex is installed and working codex-test: docker run --rm --env-file {{env_file}} {{codex_image}} codex exec --skip-git-repo-check "who are you" + +# Render plugin-source/ into platform-integrations/. Edit plugin-source/, then run this. +compile-plugins: + uv run python plugin-source/build_plugins.py render + +# Verify committed platform-integrations/ matches a fresh render of plugin-source/. +# CI and the pre-commit hook run this; nonzero exit means the source and output have drifted. +check-plugins-rendered: + uv run python plugin-source/build_plugins.py check diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md new file mode 100644 index 00000000..db1aa42a --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md @@ -0,0 +1,4 @@ +--- +description: Must be used near the end of any non-trivial turn that produced potentially reusable tools, guidance, errors, workarounds, or workflows, so those lessons are saved for future turns. +--- +Use the `evolve-lite-learn` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-publish.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-publish.md new file mode 100644 index 00000000..1b4a8a28 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-publish.md @@ -0,0 +1,4 @@ +--- +description: Publish a private guideline to a configured write-scope repo. +--- +Use the `evolve-lite-publish` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-recall.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-recall.md new file mode 100644 index 00000000..80b750d0 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-recall.md @@ -0,0 +1,4 @@ +--- +description: Must be used at the start of any non-trivial task involving code changes, debugging, repo exploration, file inspection, or environment/tooling investigation to surface stored guidance before analysis or tool use. +--- +Use the `evolve-lite-recall` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-save-trajectory.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-save-trajectory.md new file mode 100644 index 00000000..be79867e --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-save-trajectory.md @@ -0,0 +1,4 @@ +--- +description: Save the current conversation as a trajectory JSON file in OpenAI chat completion format for analysis and fine-tuning +--- +Use the `evolve-lite-save-trajectory` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-save.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-save.md new file mode 100644 index 00000000..c99d6b25 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-save.md @@ -0,0 +1,4 @@ +--- +description: Captures the current session's successful workflow and saves it as a reusable skill with SKILL.md and helper scripts +--- +Use the `evolve-lite-save` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-subscribe.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-subscribe.md new file mode 100644 index 00000000..7704549c --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-subscribe.md @@ -0,0 +1,4 @@ +--- +description: Add a shared guidelines repo (read-scope subscription or write-scope publish target) to the unified repos list. +--- +Use the `evolve-lite-subscribe` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-sync.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-sync.md new file mode 100644 index 00000000..399a5aa8 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-sync.md @@ -0,0 +1,4 @@ +--- +description: Pull the latest guidelines from every configured repo (read- and write-scope). +--- +Use the `evolve-lite-sync` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite-unsubscribe.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-unsubscribe.md new file mode 100644 index 00000000..4effbbc4 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-unsubscribe.md @@ -0,0 +1,4 @@ +--- +description: Remove a repo from the unified repos list and delete its local clone. +--- +Use the `evolve-lite-unsubscribe` skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md deleted file mode 100644 index e832b58d..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:learn -description: Run the learn skill on the current conversation ---- -Use the learn skill on the current conversation. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:publish.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:publish.md deleted file mode 100644 index 87d603ee..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:publish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:publish -description: Publish a private guideline to a configured write-scope repo ---- -Use the publish skill to share your private guidelines via a configured write-scope repo. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:recall.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:recall.md deleted file mode 100644 index e03ce399..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:recall.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:recall -description: Run the recall skill on the current conversation ---- -Use the recall skill on the current conversation. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:save-trajectory.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:save-trajectory.md deleted file mode 100644 index fd4c2a0a..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:save-trajectory.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:save-trajectory -description: Save the current conversation trajectory as a JSON file ---- -Use the save-trajectory skill on the current conversation. Follow the skill's instructions exactly. diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:subscribe.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:subscribe.md deleted file mode 100644 index 507cbe94..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:subscribe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:subscribe -description: Add a shared guidelines repo (read- or write-scope) to the unified repos list ---- -Use the subscribe skill to add a shared guidelines repo — either a read-scope subscription or a write-scope publish target. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:sync.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:sync.md deleted file mode 100644 index ee7eef8b..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:sync.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:sync -description: Pull the latest guidelines from every configured repo (read- and write-scope) ---- -Use the sync skill to update your configured guidelines repos. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:unsubscribe.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite:unsubscribe.md deleted file mode 100644 index c0c68792..00000000 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:unsubscribe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: evolve-lite:unsubscribe -description: Remove a repo from the unified repos list and delete its local clone ---- -Use the unsubscribe skill to remove a configured repo and delete its local clone. Follow the skill's instructions exactly. \ No newline at end of file diff --git a/platform-integrations/bob/evolve-lite/custom_modes.yaml b/platform-integrations/bob/evolve-lite/custom_modes.yaml index ad895187..aecef224 100644 --- a/platform-integrations/bob/evolve-lite/custom_modes.yaml +++ b/platform-integrations/bob/evolve-lite/custom_modes.yaml @@ -6,13 +6,8 @@ customModes: ⚠️ CRITICAL: THE FOLLOWING WORKFLOW IS ABSOLUTELY MANDATORY AND CANNOT BE SKIPPED ⚠️ - WORKFLOW (5 steps, strict order): + WORKFLOW (4 steps, strict order): - 0. ⚠️ BEFORE FIRST USE (MANDATORY): Read the skill SKILL.md files from .bob/skills/ directory. - - Read .bob/skills/evolve-lite:recall/SKILL.md - - Read .bob/skills/evolve-lite:learn/SKILL.md - - Read .bob/skills/evolve-lite:save-trajectory/SKILL.md - - Do this ONCE per conversation before using the skills 1. ⚠️ FIRST ACTION (MANDATORY): Use the evolve-lite:recall skill to retrieve relevant guidelines. - This MUST be your FIRST action in EVERY conversation - NO exceptions - even for simple tasks like "echo hello world" @@ -24,10 +19,14 @@ customModes: - This MUST happen BEFORE attempt_completion - NO exceptions - even if you think there's nothing to learn + Before using any skill for the first time in a conversation, read its + SKILL.md so you know the exact invocation syntax. Bob installs skills + under `.bob/skills/`; the runtime will resolve a skill by name. + ⚠️ ENFORCEMENT RULES ⚠️ - Do not skip steps. Do not reorder steps. - - Step 0 must happen before using any skill for the first time in a conversation. read_file on .bob/skills/*/SKILL.md is allowed before Step 1. - - Step 1 must happen before any other tool use (except the SKILL.md reads in Step 0). + - Step 1 must happen before any other tool use (except a one-time + SKILL.md read for a skill you have not invoked yet). - Step 3 must happen before Step 4. - Step 4 must happen before attempt_completion. - If you forget Step 1, STOP and call evolve-lite:recall immediately. @@ -40,16 +39,12 @@ customModes: - If no errors or non-obvious discoveries occurred, saving zero entities is correct — do not force low-quality entities. MEMORY SHARING (Optional): - The following skills enable sharing guidelines with others: - - evolve-lite:publish — Publish your private guidelines to a public Git repo - - evolve-lite:subscribe — Subscribe to another user's public guidelines - - evolve-lite:unsubscribe — Remove a subscription - - evolve-lite:sync — Pull latest updates from all subscribed repos (manual invocation required) - - These skills are OPTIONAL and do not affect the core workflow. Use them when you want to: - - Share your learnings with teammates - - Learn from others' guidelines - - Keep subscribed guidelines up to date + Additional evolve-lite skills handle sharing guidelines with others + (publish, subscribe, unsubscribe, sync). They are OPTIONAL and do not + affect the core workflow. Use them when you want to share your + learnings with teammates, learn from others' guidelines, or keep + subscribed guidelines up to date. Read the SKILL.md for any of these + before first use. PRE-COMPLETION GATE: Before calling attempt_completion, ask yourself: diff --git a/platform-integrations/bob/evolve-lite/lib/__init__.py b/platform-integrations/bob/evolve-lite/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/platform-integrations/bob/evolve-lite/lib/audit.py b/platform-integrations/bob/evolve-lite/lib/audit.py new file mode 100644 index 00000000..fd5c535a --- /dev/null +++ b/platform-integrations/bob/evolve-lite/lib/audit.py @@ -0,0 +1,33 @@ +"""Append-only audit log writer for .evolve/audit.log.""" + +import datetime +import json +import pathlib + + +def append(project_root=".", **fields): + """Append a JSON audit entry to .evolve/audit.log. + + Args: + project_root: Root directory that contains .evolve/. + **fields: Arbitrary key-value fields to include in the log entry. + """ + path = pathlib.Path(project_root) / ".evolve" / "audit.log" + path.parent.mkdir(parents=True, exist_ok=True) + entry = {**fields, "ts": datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z")} + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +if __name__ == "__main__": + import tempfile + + with tempfile.TemporaryDirectory() as d: + append(project_root=d, action="test", actor="alice") + log_path = __import__("pathlib").Path(d) / ".evolve" / "audit.log" + line = log_path.read_text(encoding="utf-8").strip() + entry = __import__("json").loads(line) + assert entry["action"] == "test" + assert entry["actor"] == "alice" + assert "ts" in entry + print("audit.py ok") diff --git a/platform-integrations/bob/evolve-lite/lib/config.py b/platform-integrations/bob/evolve-lite/lib/config.py new file mode 100644 index 00000000..4820494f --- /dev/null +++ b/platform-integrations/bob/evolve-lite/lib/config.py @@ -0,0 +1,518 @@ +"""Shared config reader/writer for evolve.config.yaml (project root). + +pyyaml is not assumed to be installed. This module implements a minimal +YAML reader/writer that handles the flat and single-level-nested structures +used by evolve-lite config files (scalars and lists of scalar-valued dicts). +""" + +import pathlib +import re +import sys + + +VALID_SCOPES = ("read", "write") +_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") + + +# --------------------------------------------------------------------------- +# Minimal YAML helpers (no pyyaml dependency) +# --------------------------------------------------------------------------- + + +def _strip_comments(line): + """Strip a YAML inline comment, preserving '#' inside single/double quotes.""" + quote = None + escape = False + for i, ch in enumerate(line): + if escape: + escape = False + continue + if quote: + if ch == "\\" and quote == '"': + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "#": + return line[:i].rstrip() + return line.rstrip() + + +def _parse_block(lines, start, parent_indent): + """Parse an indented block starting at `start`. + + Returns (value, next_index) where value is either: + - a list (if block starts with '- ') + - a dict (if block contains 'key: value' pairs at the same indent) + + parent_indent is the indent level of the parent key line. + """ + i = start + # Peek ahead to determine type: list or mapping + # Skip blank lines first + while i < len(lines): + stripped = _strip_comments(lines[i]) + if stripped.strip(): + break + i += 1 + if i >= len(lines): + return {}, i + + first_content = _strip_comments(lines[i]) + block_indent = len(first_content) - len(first_content.lstrip()) + + if block_indent <= parent_indent: + # Nothing actually indented under this key + return {}, i + + if first_content.strip().startswith("- "): + # List + items = [] + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if content.startswith("- "): + item_text = content[2:].strip() + if ":" in item_text: + item_dict = {} + ik, _, iv = item_text.partition(":") + item_dict[ik.strip()] = _cast(iv.strip()) + i += 1 + # Collect more keys at deeper indent for this list item + while i < len(lines): + cont = _strip_comments(lines[i]) + if not cont.strip(): + i += 1 + continue + cont_indent = len(cont) - len(cont.lstrip()) + if cont_indent <= cur_indent: + break + cont_content = cont.strip() + if ":" in cont_content: + ck, _, cv = cont_content.partition(":") + item_dict[ck.strip()] = _cast(cv.strip()) + i += 1 + items.append(item_dict) + else: + items.append(_cast(item_text)) + i += 1 + else: + i += 1 + return items, i + else: + # Nested mapping + mapping = {} + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if ":" in content: + k, _, v = content.partition(":") + k = k.strip() + v = v.strip() + if v: + mapping[k] = _cast(v) + i += 1 + else: + # nested further — recurse + nested, i = _parse_block(lines, i + 1, cur_indent) + mapping[k] = nested + else: + i += 1 + return mapping, i + + +def _parse_yaml(text): + """Parse a minimal YAML subset into a Python dict. + + Supports: + - Top-level ``key: value`` scalar pairs + - Top-level ``key:`` with indented nested mappings or list items + - Comments (#) are stripped + """ + result = {} + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + stripped = _strip_comments(line) + if not stripped.strip(): + i += 1 + continue + indent = len(stripped) - len(stripped.lstrip()) + if indent > 0: + # Skip lines that belong to a block we already consumed + i += 1 + continue + key, sep, value = stripped.partition(":") + key = key.strip() + value = value.strip() + if not key: + i += 1 + continue + if value: + result[key] = _cast(value) + i += 1 + else: + # Block value (list or nested mapping) + block_val, i = _parse_block(lines, i + 1, 0) + result[key] = block_val + return result + + +def _cast(value): + """Cast a YAML scalar string to an appropriate Python type. + + Quoted scalars stay strings — that's the whole point of YAML quoting. + Only unquoted scalars get coerced to bool / null / int / float / list. + """ + # Quoted: return the string verbatim (with single-quote unescaping). + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + stripped = value[1:-1] + if value.startswith("'"): + stripped = stripped.replace("''", "'") + return stripped + + if value in ("true", "True", "yes"): + return True + if value in ("false", "False", "no"): + return False + if value in ("null", "~", ""): + return None + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + # Empty list literal + if value == "[]": + return [] + return value + + +def _dump_yaml(obj, indent=0): + """Serialize a Python dict/list to a minimal YAML string.""" + lines = [] + prefix = " " * indent + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, dict): + lines.append(f"{prefix}{k}:") + lines.extend(_dump_yaml(v, indent + 1).splitlines()) + elif isinstance(v, list): + if not v: + lines.append(f"{prefix}{k}: []") + continue + lines.append(f"{prefix}{k}:") + for item in v: + if isinstance(item, dict): + first = True + for ik, iv in item.items(): + if first: + lines.append(f"{prefix} - {ik}: {_scalar(iv)}") + first = False + else: + lines.append(f"{prefix} {ik}: {_scalar(iv)}") + else: + lines.append(f"{prefix} - {_scalar(item)}") + else: + lines.append(f"{prefix}{k}: {_scalar(v)}") + return "\n".join(lines) + + +def _scalar(v): + """Convert a Python value to a YAML scalar string, quoting when necessary.""" + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + + # For non-string types, convert to string + if not isinstance(v, str): + return str(v) + + # Reserved YAML tokens that must be quoted + reserved_tokens = { + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "null", + "Null", + "NULL", + "~", + "yes", + "Yes", + "YES", + "no", + "No", + "NO", + "on", + "On", + "ON", + "off", + "Off", + "OFF", + } + + # YAML indicator characters that require quoting + yaml_indicators = set("-?:[]{},'&*#!|>'\"%@`") + + # Check if quoting is needed + needs_quoting = ( + v in reserved_tokens # Reserved token + or v == "" # Empty string + or v[0] in " \t" + or v[-1] in " \t" # Leading/trailing whitespace + or "#" in v # Comment character + or any(c in yaml_indicators for c in v) # YAML special characters + or v[0] in yaml_indicators # Starts with indicator + ) + + if needs_quoting: + # Use single quotes and escape embedded single quotes by doubling them + escaped = v.replace("'", "''") + return f"'{escaped}'" + + return v + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def load_config(project_root="."): + """Read evolve.config.yaml from the project root and return a dict. + + Returns {} if the file does not exist. + """ + path = pathlib.Path(project_root) / "evolve.config.yaml" + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8") + return _parse_yaml(text) + + +def save_config(cfg, project_root="."): + """Write *cfg* dict to evolve.config.yaml in the project root.""" + path = pathlib.Path(project_root) / "evolve.config.yaml" + content = _dump_yaml(cfg) + path.write_text(content + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Unified repo model (issue #217) +# --------------------------------------------------------------------------- + + +def _coerce_repo(entry): + """Normalize a single repo dict. Returns None if required fields are missing. + + Rejection is silent — callers that want to surface why a particular entry + was dropped should use ``classify_repo_entry`` to get the rejection reason + and report it however they choose. + """ + if not isinstance(entry, dict): + return None + name = entry.get("name") + remote = entry.get("remote") + if not isinstance(name, str) or not name.strip(): + return None + if not is_valid_repo_name(name.strip()): + print( + f"evolve-lite: {name!r} (skipped - invalid subscription name) — only A-Z, a-z, 0-9, '.', '_', '-' allowed", + file=sys.stderr, + ) + return None + if not isinstance(remote, str) or not remote.strip(): + return None + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name.strip(), + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + } + + +def normalize_repos(cfg): + """Return the unified ``repos`` list from *cfg* with invalid entries dropped. + + Invalid entries (missing ``name`` or ``remote``, duplicate names, unknown + scopes) are silently skipped so callers can trust every returned dict. + """ + if not isinstance(cfg, dict): + return [] + raw = cfg.get("repos") + if not isinstance(raw, list): + return [] + result = [] + seen = set() + for entry in raw: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + result.append(repo) + return result + + +def classify_repo_entry(entry): + """Return ``(repo, rejection)`` for one raw ``repos:`` list entry. + + Exactly one of ``repo`` or ``rejection`` is non-None: + - ``repo`` is the normalized dict (same shape as ``normalize_repos`` + items) when the entry is valid. + - ``rejection`` is a dict ``{"raw_name": str_or_None, "reason": str}`` + describing why the entry was dropped. ``reason`` is one of + "invalid subscription name", "missing remote", "unknown scope", or + "malformed entry". + + Used by sync.py (and similar) to surface skipped entries in user-facing + output without re-implementing validation. + """ + if not isinstance(entry, dict): + return None, {"raw_name": None, "reason": "malformed entry"} + raw_name = entry.get("name") + if not isinstance(raw_name, str) or not raw_name.strip(): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + name = raw_name.strip() + if not is_valid_repo_name(name): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + remote = entry.get("remote") + if not isinstance(remote, str) or not remote.strip(): + return None, {"raw_name": raw_name, "reason": "missing remote"} + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None, {"raw_name": raw_name, "reason": "unknown scope"} + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name, + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + }, None + + +def get_repo(cfg, name): + """Return the repo with the given name, or None.""" + for repo in normalize_repos(cfg): + if repo.get("name") == name: + return repo + return None + + +def write_repos(cfg): + """Return only the write-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "write"] + + +def read_repos(cfg): + """Return only the read-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "read"] + + +def set_repos(cfg, repos): + """Replace the ``repos`` list in-place with sanitized entries.""" + if not isinstance(cfg, dict): + return cfg + sanitized = [] + seen = set() + for entry in repos or []: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + sanitized.append(repo) + cfg["repos"] = sanitized + return cfg + + +def is_valid_repo_name(name): + """Return True if *name* is safe to use as a repo / directory name. + + Rejects leading '-' so names can't be confused with git CLI flags when + interpolated into clone paths. + """ + if not isinstance(name, str): + return False + if name in (".", "..") or name.startswith("-"): + return False + return bool(_SAFE_NAME.match(name)) + + +if __name__ == "__main__": + # Quick self-test + import tempfile + + with tempfile.TemporaryDirectory() as d: + cfg = { + "identity": {"user": "alice"}, + "repos": [ + { + "name": "memory", + "scope": "write", + "remote": "git@github.com:alice/evolve.git", + "branch": "main", + "notes": "public memory for foobar project", + }, + { + "name": "bob", + "scope": "read", + "remote": "git@github.com:bob/evolve.git", + "branch": "main", + "notes": "", + }, + ], + "sync": {"on_session_start": True}, + } + save_config(cfg, d) + loaded = load_config(d) + assert loaded["identity"]["user"] == "alice", loaded + assert loaded["sync"]["on_session_start"] is True, loaded + repos = normalize_repos(loaded) + assert len(repos) == 2, repos + assert repos[0]["scope"] == "write", repos + assert repos[1]["name"] == "bob", repos + print("config.py ok") diff --git a/platform-integrations/bob/evolve-lite/lib/entity_io.py b/platform-integrations/bob/evolve-lite/lib/entity_io.py new file mode 100644 index 00000000..b8e0eefa --- /dev/null +++ b/platform-integrations/bob/evolve-lite/lib/entity_io.py @@ -0,0 +1,298 @@ +"""Shared entity I/O utilities for the Evolve plugin. + +Handles reading and writing entities as flat markdown files with YAML +frontmatter, organized in type-nested directories. +""" + +import datetime +import getpass +import os +import re +import tempfile +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def _get_log_dir(): + """Get user-scoped log directory with restrictive permissions.""" + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + return log_dir + + +_LOG_FILE = os.path.join(_get_log_dir(), "evolve-plugin.log") + + +def log(component, message): + """Append a timestamped message to the shared log file. + + Args: + component: Short label like "retrieve" or "save". + message: The log line. + """ + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [{component}] {message}\n") + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Directory discovery +# --------------------------------------------------------------------------- + + +def get_evolve_dir(): + """Return the .evolve root directory. + + Uses ``EVOLVE_DIR`` env var if set, otherwise ``.evolve/`` in cwd. + Does not create the directory. + """ + env_dir = os.environ.get("EVOLVE_DIR") + if env_dir: + return Path(env_dir) + return Path(".evolve") + + +def find_entities_dir(): + """Locate the entities directory. + + Uses :func:`get_evolve_dir` to determine the base directory, then + returns the ``entities/`` subdirectory Path if it exists, else ``None``. + """ + c = get_evolve_dir() / "entities" + return c if c.is_dir() else None + + +def find_recall_entity_dirs(): + """Locate all directories that should be searched during recall. + + Returns the existing recall roots. Only ``entities/`` is canonical — + private entities live in ``entities/guideline/`` and shared entities + live in ``entities/subscribed/{repo}/guideline/``. + """ + evolve_dir = get_evolve_dir() + candidates = [evolve_dir / "entities"] + return [path for path in candidates if path.is_dir()] + + +def get_default_entities_dir(): + """Return (and create) the default entities directory. + + Uses ``EVOLVE_DIR`` if set, falls back to ``.evolve/entities/``. + """ + base = get_evolve_dir() / "entities" + base.mkdir(parents=True, exist_ok=True) + return base.resolve() + + +# --------------------------------------------------------------------------- +# Slugify / filename helpers +# --------------------------------------------------------------------------- + + +def slugify(text, max_length=60): + """Convert *text* to a filesystem-safe slug. + + >>> slugify("Use temp files for JSON transfer!") + 'use-temp-files-for-json-transfer' + """ + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = text.strip("-") + # Truncate at max_length, but don't break in the middle of a word + if len(text) > max_length: + text = text[:max_length].rsplit("-", 1)[0] + return text or "entity" + + +def unique_filename(directory, slug): + """Return a Path that doesn't collide with existing files in *directory*. + + Tries ``slug.md``, then ``slug-2.md``, ``slug-3.md``, etc. + """ + directory = Path(directory) + candidate = directory / f"{slug}.md" + if not candidate.exists(): + return candidate + n = 2 + while True: + candidate = directory / f"{slug}-{n}.md" + if not candidate.exists(): + return candidate + n += 1 + + +# --------------------------------------------------------------------------- +# Markdown <-> dict conversion +# --------------------------------------------------------------------------- + +_FRONTMATTER_KEYS = ("type", "trigger", "trajectory", "owner", "source", "visibility", "published_at") + + +def entity_to_markdown(entity): + """Serialize an entity dict to markdown with YAML frontmatter. + + Args: + entity: dict with keys ``content``, and optionally ``type``, + ``trigger``, ``rationale``. + + Returns: + A string suitable for writing to a ``.md`` file. + """ + lines = ["---"] + for key in _FRONTMATTER_KEYS: + val = entity.get(key) + if val: + lines.append(f"{key}: {val}") + lines.append("---") + lines.append("") + + content = entity.get("content", "") + lines.append(content) + + rationale = entity.get("rationale") + if rationale: + lines.append("") + lines.append("## Rationale") + lines.append("") + lines.append(rationale) + + lines.append("") + return "\n".join(lines) + + +def markdown_to_entity(path): + """Parse a markdown entity file back into a dict. + + Handles YAML frontmatter with simple ``key: value`` lines (no nested + structures, no PyYAML dependency). + + Returns: + dict with ``content``, ``type``, ``trigger``, ``rationale`` keys. + """ + path = Path(path) + text = path.read_text(encoding="utf-8") + + entity = {} + + # Split frontmatter + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + frontmatter = parts[1].strip() + body = parts[2] + for line in frontmatter.splitlines(): + line = line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + else: + body = text + else: + body = text + + # Split body into content and rationale + body = body.strip() + m = re.search(r"^## Rationale", body, re.MULTILINE) + if m: + content = body[: m.start()].strip() + rationale = body[m.end() :].strip() + if rationale: + entity["rationale"] = rationale + else: + content = body + + if content: + entity["content"] = content + + return entity + + +# --------------------------------------------------------------------------- +# Bulk load / write +# --------------------------------------------------------------------------- + + +def load_all_entities(entities_dir): + """Glob ``**/*.md`` under *entities_dir* and parse each file. + + Returns: + list of entity dicts. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(entities_dir.glob("**/*.md")): + try: + entity = markdown_to_entity(md) + if entity.get("content"): + entities.append(entity) + except OSError: + pass + return entities + + +def write_entity_file(directory, entity): + """Write a single entity as a markdown file under *directory*. + + The file is placed in a ``{type}/`` subdirectory. Uses atomic + write (write to ``.tmp``, then ``os.rename``). + + Returns: + Path to the written file. + """ + _ALLOWED_TYPES = {"guideline", "preference"} + entity_type = entity.get("type", "guideline") + if not isinstance(entity_type, str) or entity_type not in _ALLOWED_TYPES: + entity_type = "guideline" + entity["type"] = entity_type + type_dir = Path(directory) / entity_type + type_dir.mkdir(parents=True, exist_ok=True) + + slug = slugify(entity.get("content", "entity")) + content = entity_to_markdown(entity) + + # Write to a unique temp file first (avoids predictable .tmp collisions) + fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug) + target = None + try: + os.write(fd, content.encode("utf-8")) + os.close(fd) + fd = None + + # Atomically claim the target using O_EXCL; retry on race + while True: + target = unique_filename(type_dir, slug) + try: + claim_fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(claim_fd) + break + except FileExistsError: + continue + + os.replace(tmp_path, target) + return target + except BaseException: + if fd is not None: + os.close(fd) + if os.path.exists(tmp_path): + os.unlink(tmp_path) + # Clean up the 0-byte placeholder if the replace didn't happen + if target and os.path.exists(str(target)) and os.path.getsize(str(target)) == 0: + os.unlink(str(target)) + raise diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/SKILL.md new file mode 100644 index 00000000..b2f82264 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/SKILL.md @@ -0,0 +1,187 @@ +--- +name: evolve-lite:learn +description: Must be used near the end of any non-trivial turn that produced potentially reusable tools, guidance, errors, workarounds, or workflows, so those lessons are saved for future turns. +--- + +# Entity Generator + +## Overview + +This skill analyzes the current conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. + +## When To Use + +Use this skill after completing meaningful work in the turn, especially when encountering: +- tool failures +- permission issues +- missing dependencies +- retries or abandoned approaches +- reusable command sequences or scripts + +Examples of artifacts that must be immediately created once proven as the successful solution include: +- an inline Python, shell, or other heredoc script +- a command assembled interactively over multiple retries +- a parser or extractor implemented ad hoc during the turn +- a fallback path triggered by missing dependencies or restricted tooling + +Unless that artifact happens to be: +- code which is a trivial one-liner that future agents would not benefit from reusing +- code which embeds secrets, tokens, or user-specific sensitive data +- a guideline that would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session +- the user explicitly asked for a one-off result and not to persist helper code +- redundant because an equivalent local artifact on disk would be just as effective + +## Workflow + +### Step 1: Analyze the Conversation + +Identify from your current conversation: + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches +- **Reusable Outcome**: Did the final working solution produce a reusable script, parser, command template, or workflow that would save time on a similar task? + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `jq: command not found` | System tool unavailable in environment | created a python script to resolve the problem | Save the python script and use it in similar scenarios | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +### Step 3: Decide Whether To Save The Pipeline + +Before writing entities, determine whether the successful approach should be saved as a reusable artifact. + +Create or update a local reusable artifact when any of these are true: +- the final solution required more than a trivial one-liner +- the final solution worked around missing tools, libraries, or permissions +- the solution is likely to recur on similar tasks + +Prefer one of these artifact forms: +- a small script, saved to a stable path in the workspace or plugin, such as `scripts/`, `tools/`, or another obvious helper location. +- a documented local workflow if code is not appropriate + +If you create an artifact, record: +- its path +- what it does +- when future agents should use it first + +### Step 4: Review Existing Guidelines + +Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. + +Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. + +**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. + +If there are no existing guidelines, skip this step. + +With the existing-guideline set in mind, when you proceed to Step 5 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) + +### Step 5: Extract Entities + +If Step 3 produced an artifact, at least one entity must explicitly point to that artifact, which is likely the only entity that needs to be produced. +Otherwise, extract 3-5 proactive entities. Prioritize entities derived from errors identified in Step 2. + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + - If an approach failed due to permissions, recommend the working permission-aware approach first + - If a system tool was unavailable, recommend the saved artifact or fallback workflow first + - If an approach hit environment constraints, recommend the constraint-aware approach + +2. **Prioritize known working local artifacts over general advice** + - If the successful solution produced or reused a concrete local artifact, at least one saved entity must: + - Bad: "Use Python to parse EXIF if exiftool is missing" + - Better: "Use `/abs/path/json_get.py` for JSON field extraction when `jq` is unavailable in minimal environments." + - name the artifact by path + - state exactly when to use it + - state that it should be tried before generic tool discovery or fallback exploration + - describe the artifact by capability, not just by the original incident + +3. **Triggers should describe the broad task context that the artifact solves, not the narrow details of the original request.** + - Bad trigger: "When jq fails" + - Good trigger: "When extracting fields from JSON in constrained shells or stripped-down environments" + The trigger should generalize the working solution without becoming vague. + +4. **For retry loops, recommend the final working approach as the starting point** + - Eliminate trial and error by creating a concrete local artifact out of the successful workflow or script + +5. **Prefer entities that save future time** + - A pointer to a saved working script is more valuable than a generic reminder if both are available + +### Step 6: Output Entities JSON + +Output entities in this JSON format. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies", + "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" + } + ] +} +``` + +Allowed type values: +- guideline +- workflow +- script +- command-template + +### Step 7: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe (Recommended) + +```bash +echo '' | python3 .bob/skills/evolve-lite-learn/scripts/save_entities.py +``` + +#### Method 2: From File + +```bash +cat entities.json | python3 .bob/skills/evolve-lite-learn/scripts/save_entities.py +``` + +#### Method 3: Interactive + +```bash +python3 .bob/skills/evolve-lite-learn/scripts/save_entities.py +``` + +The script will: +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices +1. Prioritize error-derived entities first. +2. One distinct error should normally produce one prevention entity. +3. Keep entities specific and actionable. +4. Include rationale so the future agent understands why the guidance matters. +5. Use situational triggers instead of failure-based triggers. +6. Limit output to the 3-5 most valuable entities. +7. If more than five distinct errors appear, merge entities with the same root cause or fix, then rank the rest by severity, frequency, user impact, and recency before dropping the weakest ones. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/on_stop.py similarity index 100% rename from platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/on_stop.py diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.sh b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/on_stop.sh similarity index 100% rename from platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.sh rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/on_stop.sh diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/save_entities.py similarity index 88% rename from platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/save_entities.py index 090e8f48..bd300f84 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/save_entities.py @@ -11,13 +11,12 @@ from pathlib import Path # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break @@ -96,6 +95,9 @@ def main(): log(f"Skipping duplicate: {content[:60]}") continue + # Stamp owner and visibility from the script, never from stdin. + # Untrusted upstream input (a prompt-injected agent) must not be + # able to spoof either field, so unconditionally overwrite. entity["owner"] = args.user or "unknown" entity["visibility"] = "private" diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/SKILL.md similarity index 97% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/SKILL.md index e2f3aded..25b4d607 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/SKILL.md @@ -1,5 +1,5 @@ --- -name: publish +name: evolve-lite:publish description: Publish a private guideline to a configured write-scope repo. --- @@ -54,10 +54,7 @@ List files in `.evolve/entities/guideline/` and ask the user which to publish. For each selected file, run: ```bash -python3 scripts/publish.py \ - --entity "{filename}" \ - --repo "{repo}" \ - --user "{identity.user}" +python3 .bob/skills/evolve-lite-publish/scripts/publish.py --entity "{filename}" --repo "{repo}" --user "{identity.user}" ``` ### Step 6: Commit and push diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/scripts/publish.py old mode 100644 new mode 100755 similarity index 95% rename from platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/scripts/publish.py index 33e99984..cf1c128e --- a/platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/scripts/publish.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Publish a private guideline entity to a write-scope repo (Codex).""" +"""Publish a private guideline entity to a write-scope repo.""" import argparse import datetime @@ -10,13 +10,12 @@ from pathlib import Path, PurePath # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md new file mode 100644 index 00000000..b59e5b43 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md @@ -0,0 +1,96 @@ +--- +name: evolve-lite:recall +description: Must be used at the start of any non-trivial task involving code changes, debugging, repo exploration, file inspection, or environment/tooling investigation to surface stored guidance before analysis or tool use. +--- + +# Entity Retrieval + +## Overview + +This skill loads relevant stored Evolve entities into the current turn before substantive work begins. + +Use this skill first whenever the task involves: +- code changes +- debugging +- code review +- repo exploration +- file inspection +- environment/tooling investigation + +Skip only for trivial conversational requests with no local context. + +## Required Action + +Before any non-trivial local work, you must complete the recall workflow below. Reading this `SKILL.md` alone does not satisfy the skill. + +### Completion Rule + +Do not proceed to other analysis or tool use until all steps below are complete. + +1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +2. Read each matching entity file that appears relevant. +3. Summarize the applicable guidance in your own words before proceeding. +4. If no relevant entities exist, state that explicitly before proceeding. + +### Required Visible Completion Note + +Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: + +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, read , applicable guidance: ` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` + +### Minimum Acceptable Procedure + +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +2. Identify candidate entities relevant to the task. +3. Open and read those entity files. +4. Summarize what applies, or state that nothing applies. + +### Failure Conditions + +The skill is not complete if any of the following are true: + +- You only read this `SKILL.md` +- You did not inspect `${EVOLVE_DIR:-.evolve}/entities/` +- You did not read the relevant entity files +- You proceeded without stating whether guidance was found + +## How It Works + +Bob has no auto-injection hook for entity retrieval. Complete the **Required Action** workflow above on every applicable task. + +Entities can come from multiple sources: +- **Private entities**: Your own local entities (not shared) +- **Subscribed entities**: Entities cloned from any configured repo — + read-scope subscriptions and write-scope publish targets both live + under `${EVOLVE_DIR:-.evolve}/entities/subscribed/{name}/` + +## Entities Storage + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md <- private + subscribed/ + memory/ <- write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ <- read-scope clone + guideline/ + alice-guideline.md <- annotated [from: alice] +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py similarity index 63% rename from platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py index d5aa1fd5..ade892fe 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Retrieve and output entities for Codex to use as extra developer context.""" +"""Retrieve and output entities for the agent to use as extra context.""" import json import os @@ -7,13 +7,12 @@ from pathlib import Path # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break @@ -33,7 +32,12 @@ def log(message): def format_entities(entities): - """Format all entities for Codex to review.""" + """Format all entities for the agent to review. + + Entities that came from a subscribed source have their path recorded in + the private ``_source`` key (set by load_entities_with_source). These are + annotated with ``[from: {name}]`` so the agent knows their provenance. + """ header = """## Evolve entities for this task Review these stored entities and apply any that are relevant to the user's request: @@ -58,10 +62,15 @@ def format_entities(entities): def load_entities_with_source(entities_dir): - """Load markdown entities from one recall root and annotate subscribed content.""" + """Load markdown entities from one recall root and annotate subscribed content. + + Symlinks and any files inside a ``.git`` directory are skipped so we don't + surface git's own bookkeeping or sneak past path validation when a write + -scope clone lives under entities/subscribed/{name}/. + """ entities_dir = Path(entities_dir) entities = [] - for md in sorted(entities_dir.glob("**/*.md")): + for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): if md.is_symlink(): continue try: @@ -82,16 +91,28 @@ def load_entities_with_source(entities_dir): def main(): + # Hook context arrives via stdin as JSON when invoked from a hook + # (claude/claw-code/codex). Handle empty/absent stdin gracefully so the + # script also works when invoked manually (no hook upstream). + input_data = {} try: - input_data = json.load(sys.stdin) - log(f"Input keys: {list(input_data.keys())}") + raw = sys.stdin.read() + if raw.strip(): + input_data = json.loads(raw) + if isinstance(input_data, dict): + log(f"Input keys: {list(input_data.keys())}") + else: + log(f"Input type: {type(input_data).__name__}") + else: + log("stdin was empty") except json.JSONDecodeError as e: - log(f"Failed to parse JSON input: {e}") + log(f"stdin was not valid JSON ({e})") return - prompt = input_data.get("prompt", "") - if prompt: - log(f"Prompt preview: {prompt[:120]}") + if isinstance(input_data, dict): + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") log("=== Environment Variables ===") for key, value in sorted(os.environ.items()): diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/SKILL.md new file mode 100644 index 00000000..25b2bcac --- /dev/null +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/SKILL.md @@ -0,0 +1,142 @@ +--- +name: evolve-lite:save-trajectory +description: Save the current conversation as a trajectory JSON file in OpenAI chat completion format for analysis and fine-tuning +--- + +# Save Trajectory + +## Overview + +This skill saves the current session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. + +## Workflow + +### Step 1: Walk Through Conversation Messages + +Review all messages in the current conversation from start to finish. For each message, identify its type: + +- **User text messages** +- **Assistant text responses** (may include thinking) +- **Assistant tool calls** +- **Tool results** + +### Step 2: Convert to OpenAI Chat Completion Format + +Convert each message to the appropriate format: + +**User text message:** +```json +{"role": "user", "content": "the user's message text"} +``` + +**Assistant text response (no thinking):** +```json +{"role": "assistant", "content": "the assistant's response text"} +``` + +**Assistant text response (with thinking):** +```json +{"role": "assistant", "content": "the assistant's response text", "thinking": "the thinking/reasoning text"} +``` + +**Assistant tool call (no visible text):** +```json +{ + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "tool_call_id_here", + "type": "function", + "function": { + "name": "ToolName", + "arguments": "{\"param\": \"value\"}" + } + } + ] +} +``` + +**Assistant tool call with text:** +```json +{ + "role": "assistant", + "content": "text before/after the tool call", + "tool_calls": [ + { + "id": "tool_call_id_here", + "type": "function", + "function": { + "name": "ToolName", + "arguments": "{\"param\": \"value\"}" + } + } + ] +} +``` + +**Tool result:** +```json +{"role": "tool", "tool_call_id": "tool_call_id_here", "content": "the tool output text"} +``` + +#### Important Details + +- **Tool call arguments must be a JSON string**, not a nested object. Use `json.dumps()` on the arguments object. +- **Tool call IDs**: Use the actual tool call ID from the conversation. If not available, generate a unique ID like `call_001`, `call_002`, etc. +- **Multiple tool calls**: If the assistant made multiple tool calls in one turn, include all of them in a single assistant message's `tool_calls` array, followed by separate tool result messages for each. +- **Thinking blocks**: If the assistant had both thinking and text in the same turn, combine them into one message with both `content` and `thinking` fields. + +### Step 3: Clean Content + +Strip `...` tags and their contents from all message content. Use a non-greedy multiline match (e.g., `re.sub(r'[\s\S]*?', '', text).strip()`). If after stripping, a message has empty content and no tool calls, omit it. + +### Step 4: Build Envelope + +Wrap the messages array in a trajectory envelope: + +```json +{ + "model": "", + "timestamp": "2025-01-15T10:30:00Z", + "messages": [...] +} +``` + +- **model**: Use the exact model ID from the current session's environment context (e.g., the value after "You are powered by the model named …"). Do not hardcode a default — always read it from the session. +- **timestamp**: Current ISO 8601 timestamp + +### Step 5: Save via Helper Script + +Write the trajectory JSON to a temporary file using the **Write** tool, then pass the file path to the helper script: + +1. Write the JSON to `.evolve/tmp/trajectory_input.json` using the Write tool (create the directory if needed) +2. Run the helper script with the file path as an argument: + +```bash +tmp=.evolve/tmp/trajectory_input.json; mkdir -p .evolve/tmp; trap 'rm -f "$tmp"' EXIT; python3 .bob/skills/evolve-lite-save-trajectory/scripts/save_trajectory.py "$tmp" +``` + +**Important**: Do NOT use inline Python scripts, heredocs, or stdin piping to pass the trajectory JSON. Always use the Write tool to create a temp file first. This avoids escaping issues with backslashes, quotes, and newlines in conversation content. + +The script will: +- Read the trajectory JSON from the provided file path +- Create the `.evolve/trajectories/` directory if needed +- Generate a timestamped filename (`trajectory_YYYY-MM-DDTHH-MM-SS.json`) +- Write the formatted JSON +- Print confirmation with file path and message count + +## Example Output + +After saving, you should see output like: + +```text +Trajectory saved: /path/to/project/.evolve/trajectories/trajectory_2025-01-15T10-30-00.json +Messages: 12 +``` + +## Notes + +- This skill captures what's visible in the current conversation context. Very long sessions may have earlier messages compressed or summarized by the system. Include these summarized messages as-is with `role: "user"` or `role: "assistant"` as appropriate — do not skip them, since they preserve the conversation flow. +- The trajectory format is compatible with OpenAI chat completion format for downstream tooling. +- Trajectories are saved per-project in `.evolve/trajectories/` and can be version-controlled or gitignored as preferred. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/scripts/on_stop.py similarity index 100% rename from platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/scripts/on_stop.py diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/save_trajectory.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/scripts/save_trajectory.py similarity index 100% rename from platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/save_trajectory.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/scripts/save_trajectory.py diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-save/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save/SKILL.md new file mode 100644 index 00000000..9bb014e4 --- /dev/null +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-save/SKILL.md @@ -0,0 +1,470 @@ +--- +name: evolve-lite:save +description: Captures the current session's successful workflow and saves it as a reusable skill with SKILL.md and helper scripts +--- + +# Save Session as Skill + +## Overview + +This skill analyzes your current successful session and generates a new reusable skill with: +- **SKILL.md**: Comprehensive documentation with workflow steps, parameters, and examples +- **Helper scripts**: Python scripts for any programmatic operations identified in the workflow + +It extracts the workflow pattern from your conversation history (user requests, reasoning steps, tool calls, and responses) and creates parameterized files that can be invoked in future sessions. + +Use this skill when you've completed a task successfully and want to save the workflow for future reuse. + +## When to Use + +- After completing a multi-step task successfully +- When you've discovered a useful workflow pattern +- When you want to standardize a process for future use +- After solving a problem that might recur +- When the workflow involves programmatic operations that could benefit from helper scripts + +## Workflow + +### Step 1: Review Current Session + +Analyze the conversation history available in the current context, which includes: + +- **User messages**: All requests and questions from the user +- **Assistant reasoning**: Thinking tags and decision-making process +- **Tool calls**: All tools invoked with their arguments +- **Tool responses**: Results and outcomes from each tool +- **Final outcome**: The successful result achieved + +**Action**: Review the entire conversation from start to current point + +### Step 2: Identify the Workflow Pattern + +Extract the high-level workflow by: + +1. **Identifying the goal**: What was the user trying to accomplish? +2. **Grouping related actions**: Which tool calls belong together as logical steps? +3. **Recognizing decision points**: Where did the workflow branch based on conditions? +4. **Noting error handling**: How were errors or edge cases handled? +5. **Extracting the sequence**: What is the step-by-step process? + +**Example Pattern Recognition**: +``` +User Goal: "Read a file and display its contents" + +Workflow Pattern: +1. Attempt to read file at expected location +2. If access denied → check allowed directories +3. Search for file in allowed directories +4. Read file from correct location +5. Format and present results +``` + +### Step 3: Identify Parameterizable Values + +Apply **conservative parameterization** - only parameterize obvious session-specific values: + +**Parameterize**: +- Absolute file paths → `{file_path}` or `{directory}` +- Specific file names → `{filename}` +- User-specific data → `{data_value}` +- Project-specific names → `{project_name}` +- Workspace directories → `{workspace_dir}` + +**Keep Unchanged**: +- Tool names (e.g., `read_file`, `execute_command`) +- General patterns and logic +- Error handling approaches +- Workflow structure + +**Example**: +``` +Original: "Read /home/user/projects/myapp/config.json" +Parameterized: "Read {project_dir}/{config_file}" +``` + +### Step 4: Identify Script Opportunities + +Analyze the workflow to determine if helper scripts would be beneficial: + +**Generate scripts when the workflow includes**: +- Data transformation or parsing (JSON, CSV, XML processing) +- File operations (reading, writing, searching, filtering) +- API calls or HTTP requests +- Complex calculations or data analysis +- Repetitive operations that could be automated +- Integration with external tools or services + +**Script Types to Consider**: +- **Data processors**: Parse, transform, or validate data +- **File handlers**: Read, write, or manipulate files +- **API clients**: Interact with external services +- **Validators**: Check inputs or outputs +- **Formatters**: Convert data between formats + +**Example**: +``` +Workflow includes: Reading JSON file, extracting specific fields, formatting output +→ Generate: parse_and_format.py script +``` + +### Step 5: Generate Skill Document + +Create a new SKILL.md file with the following structure: + +```markdown +--- +name: {skill-name} +description: {one-line description of what this skill does} +--- + +# {Skill Title} + +## Overview + +{Brief description of the skill's purpose and when to use it} + +## Parameters + +{List parameters the user needs to provide} + +- **{param_name}**: {description and example} + +## Workflow + +### Step 1: {Step Name} + +{What this step does} + +**Action**: {Tool or approach to use} + +**Example**: +``` +{Example tool call or command} +``` + +{If helper script exists, reference it} +**Helper Script**: Use `scripts/{script_name}.py` for this operation + +{Repeat for each step} + +## Helper Scripts + +{If scripts were generated, document them} + +### {script_name}.py + +**Purpose**: {What the script does} + +**Usage**: +```bash +python3 .bob/skills/{skill-name}/scripts/{script_name}.py [arguments] +``` + +**Parameters**: +- `{param}`: {description} + +**Example**: +```bash +python3 .bob/skills/{skill-name}/scripts/parse_data.py input.json +``` + +## Error Handling + +{Common errors and how to handle them} + +## Examples + +### Example 1: {Use Case} + +**Input**: +- {param}: {value} + +**Expected Output**: +{What the user should see} + +## Notes + +{Additional guidelines or context} +``` + +### Step 6: Generate Helper Scripts + +For each identified script opportunity, create a Python script with: + +**Script Template**: +```python +#!/usr/bin/env python3 +""" +{Script description} + +Usage: + python3 {script_name}.py [arguments] + +Arguments: + {arg1}: {description} + {arg2}: {description} +""" + +import sys +import json +import argparse +from pathlib import Path + + +def main(): + """Main function implementing the script logic.""" + parser = argparse.ArgumentParser(description="{Script description}") + parser.add_argument("{arg1}", help="{description}") + parser.add_argument("{arg2}", help="{description}", nargs="?") + + args = parser.parse_args() + + # Implementation based on workflow pattern + try: + # Core logic here + result = process_data(args.{arg1}) + print(json.dumps(result, indent=2)) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def process_data(input_data): + """Process the input data according to the workflow pattern.""" + # Implementation extracted from session workflow + pass + + +if __name__ == "__main__": + main() +``` + +**Script Guidelines**: +- Include proper error handling +- Accept parameters via command-line arguments +- Output results in a structured format (JSON when appropriate) +- Include usage documentation in docstring +- Make scripts executable (`chmod +x`) + +### Step 7: Prompt for Skill Name + +Ask the user: **"What would you like to name this skill?"** + +**Naming Guidelines**: +- Use lowercase letters +- Separate words with hyphens (kebab-case) +- Be descriptive but concise +- Examples: `read-file-with-permissions`, `deploy-to-staging`, `analyze-logs` + +**Suggest a name** based on the workflow if the user is unsure: +- Extract key actions and objects from the workflow +- Combine into a descriptive name +- Example: "Read file → Check permissions → Search" → `read-file-with-permission-check` + +### Step 8: Check for Existing Skill + +Before saving, check if a skill with this name already exists: + +**Action**: Check if `.bob/skills/{skill-name}/SKILL.md` exists + +**If exists**: +- Inform the user +- Ask: "A skill with this name already exists. Would you like to:" + - Overwrite the existing skill + - Choose a different name + - Cancel + +### Step 9: Save the Skill + +**Action**: Create the skill directory structure and save all files + +1. Create directory: `.bob/skills/{skill-name}/` +2. Write SKILL.md to: `.bob/skills/{skill-name}/SKILL.md` +3. If scripts were generated: + - Create directory: `.bob/skills/{skill-name}/scripts/` + - Write each script to: `.bob/skills/{skill-name}/scripts/{script_name}.py` + - Make scripts executable: `chmod +x .bob/skills/{skill-name}/scripts/*.py` +4. Ensure proper permissions (readable by user) + +**Directory Structure**: +``` +.bob/skills/{skill-name}/ +├── SKILL.md +└── scripts/ (if applicable) + ├── script1.py + └── script2.py +``` + +**Note**: The skill is saved to the user's home directory (`.bob/skills/`) making it available across all projects. + +### Step 10: Provide Summary + +Present a clear summary to the user: + +``` +✅ Skill saved successfully! + +**Skill Name**: {skill-name} +**Location**: .bob/skills/{skill-name}/ + +**Files Created**: +- SKILL.md (workflow documentation) +{if scripts} +- scripts/{script1}.py (helper script for {purpose}) +- scripts/{script2}.py (helper script for {purpose}) +{endif} + +**Summary**: {Brief description of what the skill does} + +**Workflow Captured**: +1. {Step 1 summary} +2. {Step 2 summary} +3. {Step 3 summary} +... + +**Parameters**: +- **{param1}**: {description} +- **{param2}**: {description} + +**Helper Scripts**: +{if scripts} +- **{script1}.py**: {what it does} +- **{script2}.py**: {what it does} +{endif} + +**To use this skill**: Simply reference it by name in future sessions: "{skill-name}" +``` + +## Error Handling + +**Session Too Short**: +- If the session has fewer than 3 meaningful exchanges, inform the user +- Suggest completing more of the task before saving as a skill + +**No Clear Workflow**: +- If the conversation doesn't show a clear workflow pattern, ask the user to clarify +- Request: "Could you describe the key steps you want to capture?" + +**Skill Name Conflicts**: +- If the name already exists, provide options (overwrite, rename, cancel) +- Never silently overwrite without user confirmation + +**Invalid Skill Name**: +- If the name contains invalid characters (spaces, special chars), suggest corrections +- Example: "My Skill!" → "my-skill" + +**Script Generation Errors**: +- If script generation fails, save the SKILL.md anyway +- Inform user they can add scripts manually later +- Provide guidance on what the script should do + +## Examples + +### Example 1: Saving a File Reading Workflow (with script) + +**Session Context**: +``` +User: "Read the states.txt file and parse it into a JSON array" +Assistant: [Reads file, parses lines, converts to JSON, outputs result] +User: "Great! Save this as a skill" +``` + +**Generated Skill Name**: `read-and-parse-file` + +**Parameters Identified**: +- `filename`: The file to read +- `output_format`: Format for output (json, csv, etc.) + +**Workflow Captured**: +1. Read file from workspace +2. Parse file contents line by line +3. Convert to specified format +4. Output formatted result + +**Scripts Generated**: +- `parse_file.py`: Reads a file and converts it to JSON format + +**Files Created**: +``` +.bob/skills/read-and-parse-file/ +├── SKILL.md +└── scripts/ + └── parse_file.py +``` + +### Example 2: Saving a Deployment Workflow (with multiple scripts) + +**Session Context**: +``` +User: "Deploy the app to staging" +Assistant: [Runs tests, builds app, uploads to server, restarts service] +User: "Perfect! Save this workflow" +``` + +**Generated Skill Name**: `deploy-to-staging` + +**Parameters Identified**: +- `app_name`: Name of the application +- `server_address`: Staging server address + +**Workflow Captured**: +1. Run test suite +2. Build application +3. Upload to staging server +4. Restart service +5. Verify deployment + +**Scripts Generated**: +- `run_tests.py`: Execute test suite and report results +- `deploy.py`: Handle upload and service restart + +**Files Created**: +``` +.bob/skills/deploy-to-staging/ +├── SKILL.md +└── scripts/ + ├── run_tests.py + └── deploy.py +``` + +### Example 3: Simple Workflow (no scripts needed) + +**Session Context**: +``` +User: "List all Python files in the project" +Assistant: [Uses glob tool to find *.py files, displays results] +User: "Save this" +``` + +**Generated Skill Name**: `list-python-files` + +**Workflow Captured**: +1. Use glob tool with pattern "**/*.py" +2. Format and display results + +**Scripts Generated**: None (simple tool call, no script needed) + +**Files Created**: +``` +.bob/skills/list-python-files/ +└── SKILL.md +``` + +## Notes + +- **Conservative Parameterization**: Only obvious session-specific values are parameterized. You can manually edit the generated skill later for more customization. +- **Cross-Project Availability**: Skills are saved to `.bob/skills/` making them available in all your projects. +- **Manual Editing**: After generation, you can manually edit the SKILL.md file and scripts to refine the workflow, add more examples, or adjust parameters. +- **Script Reusability**: Generated scripts can be used standalone or called from other scripts. +- **Skill Composition**: Generated skills can reference other skills, creating powerful workflow chains. +- **Version Control**: Consider adding your `.bob/skills/` directory to version control to track skill evolution. + +## Guidelines for Better Skills + +1. **Complete the task first**: Make sure your workflow is successful before saving it as a skill +2. **Clear session**: The clearer your session workflow, the better the generated skill and scripts +3. **Descriptive names**: Choose skill names that clearly indicate what they do +4. **Test scripts**: After generation, test the helper scripts to ensure they work correctly +5. **Add context**: After generation, consider adding more examples or notes to the skill +6. **Refine scripts**: Review generated scripts and add error handling or features as needed +7. **Document parameters**: Ensure all script parameters are well-documented in both SKILL.md and script docstrings diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md similarity index 75% rename from platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md index 25a92323..48cb891d 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md @@ -1,5 +1,5 @@ --- -name: subscribe +name: evolve-lite:subscribe description: Add a shared guidelines repo (read-scope subscription or write-scope publish target) to the unified repos list. --- @@ -61,15 +61,19 @@ Ask the user for: ### Step 3: Run subscribe script ```bash -python3 plugins/evolve-lite/skills/subscribe/scripts/subscribe.py \ - --name "{name}" \ - --remote "{remote}" \ - --branch main \ - --scope "{scope}" \ - --notes "{notes}" +python3 .bob/skills/evolve-lite-subscribe/scripts/subscribe.py --name "{name}" --remote "{remote}" --branch main --scope "{scope}" --notes "{notes}" ``` ### Step 4: Confirm Tell the user the repo was added and they can run `evolve-lite:sync` immediately if they want to pull updates now. + +## Notes + +- The repo is cloned directly into `.evolve/entities/subscribed/{name}/`, + which doubles as the recall mirror +- Subscribed entities will appear in recall with `[from: {name}]` + annotations +- Read-scope repos use a shallow clone; write-scope repos use a full + clone so publish commits can be rebased and pushed cleanly diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/scripts/subscribe.py old mode 100644 new mode 100755 similarity index 77% rename from platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/scripts/subscribe.py index 26472b97..ef6b0cd0 --- a/platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/scripts/subscribe.py @@ -1,5 +1,25 @@ #!/usr/bin/env python3 -"""Add a repo to the unified ``repos`` list and clone it locally (Codex).""" +"""Add a repo to the unified ``repos`` list and clone it locally. + +Shared (multi-reader, multi-writer) repos are described in +``evolve.config.yaml``: + + repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve.git + branch: main + notes: public memory for foobar project + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org + +``scope: read`` — download-only (pulled by sync). +``scope: write`` — publish target; also pulled by sync so you see what + others push and what you have already published. +""" import argparse import os @@ -9,13 +29,12 @@ from pathlib import Path # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break @@ -113,6 +132,11 @@ def main(): remote=args.remote, ) except Exception as exc: + # Audit logging is best-effort: a failed append shouldn't roll back + # an otherwise successful subscribe (the repo is cloned, the config + # has the entry). Warn loudly so the user can fix the audit log + # path without losing the subscription. Originally rolled back on + # main's PR #245 (#244 e2e fix). print(f"Warning: failed to append audit entry for subscribe: {exc}", file=sys.stderr) print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md similarity index 61% rename from platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md index 3811958f..6c9173f2 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/sync/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md @@ -1,5 +1,5 @@ --- -name: sync +name: evolve-lite:sync description: Pull the latest guidelines from every configured repo (read- and write-scope). --- @@ -17,11 +17,18 @@ unpushed local publish commits are preserved. ### Step 1: Run sync script ```bash -python3 plugins/evolve-lite/skills/sync/scripts/sync.py +python3 .bob/skills/evolve-lite-sync/scripts/sync.py ``` ### Step 2: Display summary Show the script output to the user. If there are no repos configured, -tell them they can add one with `evolve-lite:subscribe`. If there are no -changes, explain that everything is already up to date. +tell them they can add one with `evolve-lite:subscribe`. If there +are no changes, explain that everything is already up to date. + +## Notes + +- Read-scope repos are mirrored exactly via `git fetch` + `git reset --hard` +- Write-scope repos use `git fetch` + `git rebase` so unpushed local + publish commits are preserved +- Sync results are logged to `.evolve/audit.log` diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py old mode 100644 new mode 100755 similarity index 81% rename from platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py index b590511f..33c34716 --- a/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py @@ -1,5 +1,17 @@ #!/usr/bin/env python3 -"""Pull the latest guidelines from every configured repo (Codex).""" +"""Pull the latest guidelines from every configured repo. + +Every repo in ``evolve.config.yaml`` (both read- and write-scope) is cloned +into ``.evolve/entities/subscribed/{name}/`` so recall sees everything through +a single root. Publish commits stay local until pushed, so write-scope repos +use ``git fetch`` + ``git rebase`` (preserves unpushed commits) while +read-scope repos use ``git fetch`` + ``git reset --hard`` (exact mirror). + +Usage: + --quiet Suppress output if no changes. + --config PATH Path to config file (default: evolve.config.yaml at project root). + --session-start Apply the ``sync.on_session_start`` gate (automatic hook runs). +""" import argparse import os @@ -8,13 +20,12 @@ from pathlib import Path # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break @@ -24,7 +35,7 @@ raise ImportError(f"Cannot find plugin lib directory above {_script}") sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 -from config import is_valid_repo_name, load_config, normalize_repos # noqa: E402 +from config import classify_repo_entry, load_config # noqa: E402 _GIT_TIMEOUT = 30 # seconds @@ -118,9 +129,24 @@ def main(): if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: sys.exit(0) - repos = normalize_repos(cfg) + raw_entries = cfg.get("repos") if isinstance(cfg, dict) else None + if not isinstance(raw_entries, list): + raw_entries = [] + + repos = [] + rejections = [] + seen = set() + for entry in raw_entries: + repo, rejection = classify_repo_entry(entry) + if rejection is not None: + rejections.append(rejection) + continue + if repo["name"] in seen: + continue + seen.add(repo["name"]) + repos.append(repo) - if not repos: + if not repos and not rejections: if not args.quiet: print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") sys.exit(0) @@ -132,22 +158,18 @@ def main(): total_delta = {} any_changes = False + for rejection in rejections: + raw_name = rejection["raw_name"] + reason = rejection["reason"] + label = repr(raw_name) if raw_name else "" + summaries.append(f"{label} (skipped - {reason})") + for repo in repos: - raw_name = repo.get("name", "unknown") + name = repo["name"] scope = repo.get("scope", "read") branch = repo.get("branch", "main") remote = repo.get("remote") - if not is_valid_repo_name(raw_name): - summaries.append(f"{raw_name!r} (skipped - invalid subscription name)") - continue - name = raw_name.strip() - - if not isinstance(branch, str) or not branch.strip(): - summaries.append(f"{raw_name!r} (skipped - invalid subscription config)") - continue - branch = branch.strip() - subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/SKILL.md similarity index 77% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/SKILL.md index ee6a0e14..c8ba21b5 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/SKILL.md @@ -1,5 +1,5 @@ --- -name: unsubscribe +name: evolve-lite:unsubscribe description: Remove a repo from the unified repos list and delete its local clone. --- @@ -19,7 +19,7 @@ commits will be lost. Run: ```bash -python3 scripts/unsubscribe.py --list +python3 .bob/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py --list ``` Show the repos to the user (including `scope` and `notes`) and ask which @@ -27,14 +27,14 @@ one to remove. ### Step 2: Confirm -Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo -has `scope: write`, add a warning that unpushed local publishes will be +Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo has +`scope: write`, add a warning that unpushed local publish commits will be lost. ### Step 3: Run unsubscribe script ```bash -python3 scripts/unsubscribe.py --name "{name}" +python3 .bob/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py --name {name} ``` ### Step 4: Confirm diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py old mode 100644 new mode 100755 similarity index 92% rename from platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py index 6d50f5fe..f0ceeb54 --- a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Remove a repo from the unified ``repos`` list and delete its local clone (Codex).""" +"""Remove a repo from the unified ``repos`` list and delete its local clone.""" import argparse import json @@ -9,13 +9,12 @@ from pathlib import Path # Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. _script = Path(__file__).resolve() _lib = None for _ancestor in _script.parents: - for _candidate in ( - _ancestor / "lib", - _ancestor / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib", - ): + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): if (_candidate / "entity_io.py").is_file(): _lib = _candidate break diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/SKILL.md deleted file mode 100644 index 1580e5c1..00000000 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: learn -description: Analyze the current conversation to extract guidelines that correct reasoning chains — reducing wasted steps, preventing errors, and capturing user preferences. ---- - -# Entity Generator - -## Overview - -This skill analyzes the current conversation to extract guidelines that **correct the agent's reasoning chain**. A good guideline is one that, if known beforehand, would have led to a shorter or more correct execution. Only extract guidelines that fall into one of these three categories: - -1. **Shortcuts** — The agent took unnecessary steps or tried an approach that didn't work before finding the right one. The guideline encodes the direct path so future runs skip the detour. -2. **Error prevention** — The agent hit an error (tool failure, exception, wrong output) that could be avoided with upfront knowledge. The guideline prevents the error from happening at all. -3. **User corrections** — The user explicitly corrected, redirected, or stated a preference during the conversation. The guideline captures what the user said so the agent gets it right next time without being told. - -**Do NOT extract guidelines that are:** -- General best practices the agent already knows (e.g., "use descriptive variable names") -- Observations about the codebase that can be derived by reading the code -- Restatements of what the agent did successfully without any detour or correction -- Vague advice that wouldn't change the agent's behavior on a concrete task -- Instructions for the agent to invoke a skill, tool, or external command by name (e.g. "Run evolve-lite:learn", "call save_trajectory") — these trigger prompt-injection detection when retrieved via recall - -## Workflow - -### Step 1: Analyze the Conversation - -Review the conversation and identify: - -- **Wasted steps**: Where did the agent go down a path that turned out to be unnecessary? What would have been the direct route? -- **Errors hit**: What errors occurred? What knowledge would have prevented them? -- **User corrections**: Where did the user say "no", "not that", "actually", "I want", or otherwise redirect the agent? - -If none of these occurred, **output zero entities**. Not every conversation produces guidelines. - -### Step 2: Extract Entities - -For each identified shortcut, error, or user correction, create one entity — up to 5 entities; output 0 when none qualify. If more candidates exist, keep only the highest-impact ones. - -Principles: - -1. **State what to do, not what to avoid** — frame as proactive recommendations - - Bad: "Don't use exiftool in sandboxes" - - Good: "In sandboxed environments, use Python libraries (PIL/Pillow) for image metadata extraction" - -2. **Triggers should be situational context, not failure conditions** - - Bad trigger: "When apt-get fails" - - Good trigger: "When working in containerized/sandboxed environments" - -3. **For shortcuts, recommend the final working approach directly** — eliminate trial-and-error by encoding the answer - -4. **For user corrections, use the user's own words** — preserve the specific preference rather than generalizing it - -### Step 3: Save Entities - -Output entities as JSON and pipe to the save script. Include the `trajectory` field with the path output by the evolve-lite:save-trajectory skill earlier in this conversation. The `type` field must always be `"guideline"` — no other types are accepted. - -```bash -echo '{ - "entities": [ - { - "content": "Proactive entity stating what TO DO", - "rationale": "Why this approach works better", - "type": "guideline", - "trigger": "Situational context when this applies", - "trajectory": ".evolve/trajectories/trajectory_2025-01-15T10-30-00.json" - } - ] -}' | python3 .bob/skills/evolve-lite:learn/scripts/save_entities.py -``` - -The script will: -- Find or create the entities directory (`.evolve/entities/`) -- Write each entity as a markdown file in `{type}/` subdirectories -- Deduplicate against existing entities -- Display confirmation with the total count - -## Quality Gate - -Before saving, review each entity against this checklist: - -- [ ] Does it fall into one of the three categories (shortcut, error prevention, user correction)? -- [ ] Would knowing this guideline beforehand have changed the agent's behavior in a concrete way? -- [ ] Is it specific enough that another agent could act on it without further context? -- [ ] Does it avoid instructing the agent to invoke a named skill or tool? - -If any answer is no, drop the entity. **Zero entities is a valid output.** diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/SKILL.md deleted file mode 100644 index 6349e719..00000000 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/SKILL.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -name: recall -description: Retrieves relevant entities from a knowledge base to inject context-appropriate entities before task execution. ---- - -# Entity Retrieval - -## Overview - -This skill retrieves relevant entities from a stored knowledge base based on the current task context. Read all stored entities from the entities directory and apply any relevant ones to the current task. - -Entities can come from multiple sources: -- **Private entities**: Your own local entities (not shared) -- **Subscribed entities**: Entities cloned from any configured repo — - read-scope subscriptions and write-scope publish targets both live - under `.evolve/entities/subscribed/{name}/` - -## How It Works - -1. List all `.md` files under `.evolve/entities/` and its subdirectories -2. Read each file — the YAML frontmatter contains `type` and `trigger`, - the body contains the entity content and rationale -3. Review each entity for relevance to the current task -4. Apply relevant entities as additional context for your work - -**Directory structure**: -- `.evolve/entities/guideline/` - Your private entities -- `.evolve/entities/subscribed/{name}/` - Cloned repos (read- or write-scope) - -Write-scope clones are also where `evolve-lite:publish` lands new -guidelines, so your published entities show up here too. - -## Usage - -```bash -python3 scripts/retrieve_entities.py -``` - -This retrieves all entities from all sources (private, plus everything -under `.evolve/entities/subscribed/`). - -## Entities Storage - -Entities are stored as individual markdown files in `.evolve/entities/`, -organized by source: - -```text -.evolve/entities/ - guideline/ # Private entities - use-context-managers.md - subscribed/ - memory/ # write-scope clone (publishes land here) - guideline/ - my-published-guideline.md - alice/ # read-scope clone - guideline/ - error-handling.md -``` - -Each file uses markdown with YAML frontmatter: - -```markdown ---- -type: guideline -trigger: When processing files or managing resources -visibility: private -owner: alice ---- - -Use context managers for file operations - -## Rationale - -Ensures proper resource cleanup -``` - -## Entity Annotations - -Subscribed entities are annotated with their source: -``` -- **[guideline]** [from: alice] Use context managers for file operations - - _Rationale: Ensures proper resource cleanup_ - - _When: When processing files or managing resources_ -``` diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/scripts/retrieve_entities.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/scripts/retrieve_entities.py deleted file mode 100755 index eff3fc2a..00000000 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/scripts/retrieve_entities.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -"""Retrieve and output entities for Bob to filter.""" - -import sys -from pathlib import Path - -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) - break - -from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402 - - -def log(message): - _log("retrieve", message) - - -def format_entities(entities): - """Format all entities for Bob to review. - - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so Bob knows their provenance. - """ - header = """## Entities for this task - -Review these entities and apply any relevant ones: - -""" - items = [] - for e in entities: - content = e.get("content") - if not content: - continue - source = e.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{e.get('type', 'general')}]** {content}" - if e.get("rationale"): - item += f"\n - _Rationale: {e['rationale']}_" - if e.get("trigger"): - item += f"\n - _When: {e['trigger']}_" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Glob all .md files under entities_dir and parse each. - - Entities stored under entities/subscribed/{name}/ have ``_source`` set to - the repo name so format_entities can annotate them. Both read-scope and - write-scope clones live under entities/subscribed/{name}/, so write-scope - publishes land in this same path and are picked up automatically. - - Symlinks and any files inside a ``.git`` directory are skipped so we - don't surface git's own bookkeeping or sneak past path validation. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - except (OSError, UnicodeDecodeError): - continue - entity.pop("_source", None) - if not entity.get("content"): - continue - try: - rel_parts = md.relative_to(entities_dir).parts - except ValueError: - rel_parts = md.parts - if rel_parts and rel_parts[0] == "subscribed" and len(rel_parts) > 1: - entity["_source"] = rel_parts[1] - entities.append(entity) - return entities - - -def main(): - log("Script started") - - entities_dir = find_entities_dir() - log(f"Entities dir: {entities_dir}") - - entities = [] - if entities_dir: - entities = load_entities_with_source(entities_dir) - - if not entities: - log("No entities found") - return - - log(f"Loaded {len(entities)} entities") - output = format_entities(entities) - print(output) - log(f"Output {len(output)} chars to stdout") - - -if __name__ == "__main__": - main() - -# Made with Bob diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py deleted file mode 100644 index 9664820f..00000000 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -""" -Save Trajectory Script -Reads trajectory JSON from stdin and writes it to .evolve/trajectories/ -with a timestamped filename. -""" - -import json -import os -import sys -from datetime import datetime, timezone -from pathlib import Path - - -def find_evolve_dir(): - """Walk up from CWD to find an existing .evolve/ directory, or return default.""" - cwd = Path.cwd() - for ancestor in [cwd] + list(cwd.parents): - candidate = ancestor / ".evolve" - if candidate.is_dir(): - return candidate - return cwd / ".evolve" - - -def main(): - """Read trajectory JSON from stdin and write to a timestamped file.""" - try: - trajectory = json.load(sys.stdin) - except json.JSONDecodeError as e: - print(f"Error: Invalid JSON input - {e}", file=sys.stderr) - sys.exit(1) - - evolve_dir = find_evolve_dir() - trajectories_dir = evolve_dir / "trajectories" - trajectories_dir.mkdir(parents=True, exist_ok=True) - - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S") - base = f"trajectory_{timestamp}" - - # Atomic create with collision avoidance for same-second writes - n = 0 - while True: - filename = f"{base}.json" if n == 0 else f"{base}_{n}.json" - output_path = trajectories_dir / filename - try: - fd = os.open(str(output_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - break - except FileExistsError: - n += 1 - - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(trajectory, f, indent=2, ensure_ascii=False) - - try: - rel_path = output_path.relative_to(evolve_dir.parent) - except ValueError: - rel_path = output_path - - messages = len(trajectory.get("messages", [])) - print(f"Trajectory saved: {rel_path}") - print(f"Messages: {messages}") - - -if __name__ == "__main__": - main() diff --git a/platform-integrations/claude/plugins/evolve-lite/.claude-plugin/plugin.json b/platform-integrations/claude/plugins/evolve-lite/.claude-plugin/plugin.json index 4236e49f..fa0dd8f5 100644 --- a/platform-integrations/claude/plugins/evolve-lite/.claude-plugin/plugin.json +++ b/platform-integrations/claude/plugins/evolve-lite/.claude-plugin/plugin.json @@ -1,9 +1,9 @@ { "name": "evolve-lite", "version": "1.1.0", - "description": "Learn from conversations with auto-generated entities", + "description": "Recall, save, and share reusable Evolve entities.", "author": { - "name": "Vinod Muthusamy" + "name": "AgentToolkit" }, - "skills": "./skills/" + "skills": "./skills/evolve-lite/" } diff --git a/platform-integrations/claude/plugins/evolve-lite/hooks/hooks.json b/platform-integrations/claude/plugins/evolve-lite/hooks/hooks.json index 4e309f86..1d282a7e 100644 --- a/platform-integrations/claude/plugins/evolve-lite/hooks/hooks.json +++ b/platform-integrations/claude/plugins/evolve-lite/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/recall/scripts/retrieve_entities.py" + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/recall/scripts/retrieve_entities.py" } ] } @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/sync/scripts/sync.py --quiet" + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/sync/scripts/sync.py --quiet" } ] } @@ -28,11 +28,11 @@ "hooks": [ { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/save-trajectory/scripts/on_stop.py" + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/save-trajectory/scripts/on_stop.py" }, { "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/on_stop.py" + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/learn/scripts/on_stop.py" } ] } diff --git a/platform-integrations/claude/plugins/evolve-lite/lib/config.py b/platform-integrations/claude/plugins/evolve-lite/lib/config.py index 9414862f..4820494f 100644 --- a/platform-integrations/claude/plugins/evolve-lite/lib/config.py +++ b/platform-integrations/claude/plugins/evolve-lite/lib/config.py @@ -328,7 +328,12 @@ def save_config(cfg, project_root="."): def _coerce_repo(entry): - """Normalize a single repo dict. Returns None if required fields are missing.""" + """Normalize a single repo dict. Returns None if required fields are missing. + + Rejection is silent — callers that want to surface why a particular entry + was dropped should use ``classify_repo_entry`` to get the rejection reason + and report it however they choose. + """ if not isinstance(entry, dict): return None name = entry.get("name") @@ -347,10 +352,6 @@ def _coerce_repo(entry): if isinstance(scope, str): scope = scope.strip() if scope not in VALID_SCOPES: - print( - f"evolve-lite: ignoring repo entry {name!r} — unknown scope {entry.get('scope')!r} (expected one of {', '.join(VALID_SCOPES)})", - file=sys.stderr, - ) return None branch = entry.get("branch", "main") if not isinstance(branch, str) or not branch.strip(): @@ -389,6 +390,51 @@ def normalize_repos(cfg): return result +def classify_repo_entry(entry): + """Return ``(repo, rejection)`` for one raw ``repos:`` list entry. + + Exactly one of ``repo`` or ``rejection`` is non-None: + - ``repo`` is the normalized dict (same shape as ``normalize_repos`` + items) when the entry is valid. + - ``rejection`` is a dict ``{"raw_name": str_or_None, "reason": str}`` + describing why the entry was dropped. ``reason`` is one of + "invalid subscription name", "missing remote", "unknown scope", or + "malformed entry". + + Used by sync.py (and similar) to surface skipped entries in user-facing + output without re-implementing validation. + """ + if not isinstance(entry, dict): + return None, {"raw_name": None, "reason": "malformed entry"} + raw_name = entry.get("name") + if not isinstance(raw_name, str) or not raw_name.strip(): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + name = raw_name.strip() + if not is_valid_repo_name(name): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + remote = entry.get("remote") + if not isinstance(remote, str) or not remote.strip(): + return None, {"raw_name": raw_name, "reason": "missing remote"} + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None, {"raw_name": raw_name, "reason": "unknown scope"} + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name, + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + }, None + + def get_repo(cfg, name): """Return the repo with the given name, or None.""" for repo in normalize_repos(cfg): diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md new file mode 100644 index 00000000..1de6b643 --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md @@ -0,0 +1,200 @@ +--- +name: learn +description: Must be used near the end of any non-trivial turn that produced potentially reusable tools, guidance, errors, workarounds, or workflows, so those lessons are saved for future turns. +context: fork +--- + +# Entity Generator + +## Overview + +This skill analyzes the current conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. + +## When To Use + +Use this skill after completing meaningful work in the turn, especially when encountering: +- tool failures +- permission issues +- missing dependencies +- retries or abandoned approaches +- reusable command sequences or scripts + +Examples of artifacts that must be immediately created once proven as the successful solution include: +- an inline Python, shell, or other heredoc script +- a command assembled interactively over multiple retries +- a parser or extractor implemented ad hoc during the turn +- a fallback path triggered by missing dependencies or restricted tooling + +Unless that artifact happens to be: +- code which is a trivial one-liner that future agents would not benefit from reusing +- code which embeds secrets, tokens, or user-specific sensitive data +- a guideline that would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run /evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session +- the user explicitly asked for a one-off result and not to persist helper code +- redundant because an equivalent local artifact on disk would be just as effective + +## Workflow + +### Step 0: Load the Conversation + +This skill runs in a forked context. **You cannot see the parent conversation directly** — the only way to access it is by reading the trajectory file the save-trajectory stop hook just wrote to disk. Do not infer from your own (empty) conversation that there's nothing to learn; the parent's real work is in that file. + +The stop-hook message (produced by `on_stop.py`) contains the literal marker `The saved trajectory path is: ` — a copy of the session transcript saved inside the project tree at `.evolve/trajectories/claude-transcript_.jsonl`. Take everything after the colon, strip surrounding whitespace and quotes, and use the result as `saved_trajectory_path`. You will also attach this exact path to each entity's `trajectory` field in Step 6. + +**Read this file with the `Read` tool — do NOT shell out.** `Read` pages large files natively (use its `offset` / `limit` parameters if needed). Do not use `cat`, `head`, `wc`, `find`, or `python3 -c` loops on the transcript — those trigger a permission prompt for every invocation and are unnecessary. + +If the saved trajectory file does not exist (e.g., the save-trajectory hook did not run, or no marker was provided), output zero entities and exit. Do NOT fall back to reading the live session transcript under `~/.claude/projects/` — that path is outside the project tree, triggers permission prompts, and may be larger than the fork can consume. + +The transcript is JSONL: each line is a separate JSON object. Filter for `"type": "assistant"` and `"type": "human"` lines, then reconstruct the flow from `message.content`. Look for tool calls, errors in tool results, and user corrections. + +### Step 1: Analyze the Conversation + +Identify from your current conversation (loaded from the transcript): + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches +- **Reusable Outcome**: Did the final working solution produce a reusable script, parser, command template, or workflow that would save time on a similar task? + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `jq: command not found` | System tool unavailable in environment | created a python script to resolve the problem | Save the python script and use it in similar scenarios | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +### Step 3: Decide Whether To Save The Pipeline + +Before writing entities, determine whether the successful approach should be saved as a reusable artifact. + +Create or update a local reusable artifact when any of these are true: +- the final solution required more than a trivial one-liner +- the final solution worked around missing tools, libraries, or permissions +- the solution is likely to recur on similar tasks + +Prefer one of these artifact forms: +- a small script, saved to a stable path in the workspace or plugin, such as `scripts/`, `tools/`, or another obvious helper location. +- a documented local workflow if code is not appropriate + +If you create an artifact, record: +- its path +- what it does +- when future agents should use it first + +### Step 4: Review Existing Guidelines + +Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. + +Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. + +**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. + +If there are no existing guidelines, skip this step. + +With the existing-guideline set in mind, when you proceed to Step 5 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) + +### Step 5: Extract Entities + +If Step 3 produced an artifact, at least one entity must explicitly point to that artifact, which is likely the only entity that needs to be produced. +Otherwise, extract 3-5 proactive entities. Prioritize entities derived from errors identified in Step 2. + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + - If an approach failed due to permissions, recommend the working permission-aware approach first + - If a system tool was unavailable, recommend the saved artifact or fallback workflow first + - If an approach hit environment constraints, recommend the constraint-aware approach + +2. **Prioritize known working local artifacts over general advice** + - If the successful solution produced or reused a concrete local artifact, at least one saved entity must: + - Bad: "Use Python to parse EXIF if exiftool is missing" + - Better: "Use `/abs/path/json_get.py` for JSON field extraction when `jq` is unavailable in minimal environments." + - name the artifact by path + - state exactly when to use it + - state that it should be tried before generic tool discovery or fallback exploration + - describe the artifact by capability, not just by the original incident + +3. **Triggers should describe the broad task context that the artifact solves, not the narrow details of the original request.** + - Bad trigger: "When jq fails" + - Good trigger: "When extracting fields from JSON in constrained shells or stripped-down environments" + The trigger should generalize the working solution without becoming vague. + +4. **For retry loops, recommend the final working approach as the starting point** + - Eliminate trial and error by creating a concrete local artifact out of the successful workflow or script + +5. **Prefer entities that save future time** + - A pointer to a saved working script is more valuable than a generic reminder if both are available + +### Step 6: Output Entities JSON + +Output entities in this JSON format. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies", + "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" + } + ] +} +``` + +Allowed type values: +- guideline +- workflow +- script +- command-template + +### Step 7: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe (Recommended) + +```bash +echo '' | python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/learn/scripts/save_entities.py +``` + +#### Method 2: From File + +```bash +cat entities.json | python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/learn/scripts/save_entities.py +``` + +#### Method 3: Interactive + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/learn/scripts/save_entities.py +``` + +The script will: +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices +1. Prioritize error-derived entities first. +2. One distinct error should normally produce one prevention entity. +3. Keep entities specific and actionable. +4. Include rationale so the future agent understands why the guidance matters. +5. Use situational triggers instead of failure-based triggers. +6. Limit output to the 3-5 most valuable entities. +7. If more than five distinct errors appear, merge entities with the same root cause or fix, then rank the rest by severity, frequency, user impact, and recency before dropping the weakest ones. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py new file mode 100644 index 00000000..d26afbcb --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Stop hook that triggers the learn skill to extract guidelines.""" + +import json +import sys +from pathlib import Path + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + if input_data.get("stop_hook_active") is True: + return + + transcript_path = input_data.get("transcript_path", "") + reason = "Run the /evolve-lite:learn skill." + if transcript_path: + session_id = Path(transcript_path).stem.removeprefix("claude-transcript_") + if session_id: + saved_trajectory = f".evolve/trajectories/claude-transcript_{session_id}.jsonl" + reason += f" The saved trajectory path is: {saved_trajectory}" + + print( + json.dumps( + { + "decision": "block", + "reason": reason, + "suppressOutput": True, + "systemMessage": "Running the evolve-lite learn skill...", + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh new file mode 100755 index 00000000..b62b110c --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash +INPUT=$(cat) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') + +# Guard against infinite loop — don't inject if already continuing from a stop hook +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +echo '{ + "decision": "block", + "reason": "Run the /evolve-lite:learn skill to extract guidelines from this conversation.", + "suppressOutput": true, + "systemMessage": "Running the evolve-lite learn skill..." +}' diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py similarity index 76% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py index 962dceea..bd300f84 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py @@ -10,13 +10,21 @@ import sys from pathlib import Path -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from entity_io import ( # noqa: E402 find_entities_dir, get_default_entities_dir, @@ -43,7 +51,6 @@ def main(): parser.add_argument("--user", default=None, help="Stamp owner on every entity written") args = parser.parse_args() - # Read entities from stdin try: input_data = json.load(sys.stdin) log(f"Received input with keys: {list(input_data.keys())}") @@ -64,7 +71,6 @@ def main(): log(f"Received {len(new_entities)} new entities") - # Find or create entities directory entities_dir = find_entities_dir() if entities_dir: entities_dir = entities_dir.resolve() @@ -75,12 +81,10 @@ def main(): log(f"Created new dir: {entities_dir}") print(f"Created new entities dir: {entities_dir}") - # Load existing entities for dedup existing_entities = load_all_entities(entities_dir) existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} log(f"Existing entities: {len(existing_entities)}") - # Write new entities as markdown files added_count = 0 for entity in new_entities: content = entity.get("content") @@ -91,9 +95,10 @@ def main(): log(f"Skipping duplicate: {content[:60]}") continue - # Always stamp owner and visibility from script, not from stdin - if args.user: - entity["owner"] = args.user + # Stamp owner and visibility from the script, never from stdin. + # Untrusted upstream input (a prompt-injected agent) must not be + # able to spoof either field, so unconditionally overwrite. + entity["owner"] = args.user or "unknown" entity["visibility"] = "private" path = write_entity_file(entities_dir, entity) diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md new file mode 100644 index 00000000..b41a07be --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md @@ -0,0 +1,145 @@ +--- +name: publish +description: Publish a private guideline to a configured write-scope repo. +--- + +# Publish a Guideline + +## Overview + +Publish one or more private guidelines from `.evolve/entities/guideline/` +into a configured **write-scope** repo. The entity is stamped with +`visibility: public`, `owner`, `published_at`, and `source`, moved into +the local clone of the write repo, and committed / pushed to the remote. + +The same local clone is also what `/evolve-lite:sync` pulls from — so you +and anyone else publishing to the same repo stay in sync. + +## Workflow + +### Step 1: Require a write-scope repo + +Read `evolve.config.yaml`. If no entry has `scope: write`, tell the user: + +> "You need at least one write-scope repo to publish to. Run /evolve-lite:subscribe with --scope write to set one up, then come back." + +Then stop. + +If `identity.user` is missing, ask for it and add it to the config. + +### Step 2: First-time setup + +Ensure `.evolve/` is gitignored at the project root: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 3: Pick the target write-scope repo + +Filter `repos:` to entries with `scope: write` (Step 1 already aborted if +there were zero, so at least one exists here). + +- Exactly one entry → use it as default. +- Multiple entries → show a numbered list with `notes` and ask which to publish to. + +Let `{repo}` be the chosen repo name and `{branch}` its configured branch (default `main`). + +### Step 4: List and select entities + +List files in `.evolve/entities/guideline/` and ask the user which to publish. + +### Step 5: Run publish script + +For each selected file, run: + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/publish/scripts/publish.py \ + --entity "{filename}" \ + --repo "{repo}" \ + --user "{identity.user}" +``` + +### Step 6: Commit and push + +Build `{names}` as a comma-joined list of selected filenames, and +`{guideline_paths}` as a space-joined list of the corresponding +`guideline/{filename}` paths inside the clone (the files the publish +script just wrote). + +```bash +git -C ".evolve/entities/subscribed/{repo}" add -- {guideline_paths} +git -C ".evolve/entities/subscribed/{repo}" commit -m "[evolve] publish: {names}" +git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" +``` + +On push success, continue to Step 7. + +### Step 6a: Recover from non-fast-forward rejection + +If the push fails and stderr mentions `rejected` / `non-fast-forward` +/ `fetch first`, another writer pushed to `{branch}` in between. +Rebase the local commit and push once more: + +```bash +git -C ".evolve/entities/subscribed/{repo}" fetch origin "{branch}" +git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" +``` + +- Rebase clean → retry `git push origin "{branch}"` once, then Step 7. +- Rebase conflicted → attempt to resolve, then hand off for user + review. Do not `git rebase --continue` or `git push` without an + explicit user confirmation. + + 1. `git -C ".evolve/entities/subscribed/{repo}" status --porcelain` + lists the conflicted paths. If any are `UD`, `DU`, or binary, + skip to the abort step — those aren't safe to auto-resolve. + 2. For each `UU`/`AA` file, read the conflict markers. During a + rebase, `<<<<<<< HEAD` is the **remote's** version and the + section under the commit sha is the **publish change** being + replayed (opposite of a regular merge). Write an + intent-preserving resolution; don't `git add` yet. + 3. Show the user the diff (`git -C ".evolve/entities/subscribed/{repo}" diff HEAD -- {file}`) per + resolved file with a one-line strategy summary, and ask whether + to **continue** (stage + `rebase --continue` + push) or **abort** + (roll back for manual resolution). + 4. On **continue**: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" add {resolved-files} + git -C ".evolve/entities/subscribed/{repo}" rebase --continue + git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" + ``` + + Then Step 7. If `rebase --continue` surfaces a new conflict, loop + from step 1. + 5. On **abort** — user declined, conflict isn't safely resolvable, + or the proposed merge feels unsafe: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" rebase --abort + ``` + + The local publish commit is preserved at + `.evolve/entities/subscribed/{repo}` but not on the remote. Tell + the user to either (a) resolve manually in that directory + (`git fetch origin {branch} && git rebase origin/{branch}`, fix + conflicts, `git add` + `git rebase --continue`, `git push origin + {branch}`) or (b) re-run `/evolve-lite:publish` with a different + filename if the conflict is a shared name. + +If the push fails for any other reason (auth, network, missing remote +ref), surface git's error and stop — rebase will not help. + +### Step 7: Confirm + +Tell the user what was published and to which repo. + +## Notes + +- Published entities are **moved** from `.evolve/entities/guideline/` into + the write-scope clone at `.evolve/entities/subscribed/{repo}/guideline/`, + with `visibility: public`, `owner: {user}`, `published_at`, and `source` + stamped in frontmatter +- The original private entity is deleted after successful publication +- All publish actions are logged to `.evolve/audit.log` diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/scripts/publish.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py similarity index 88% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/scripts/publish.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py index debea4de..cf1c128e 100755 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/scripts/publish.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Publish a private guideline entity to a write-scope repo (Bob).""" +"""Publish a private guideline entity to a write-scope repo.""" import argparse import datetime @@ -9,17 +9,24 @@ import tempfile from pathlib import Path, PurePath -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break - +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 -from config import get_repo, load_config, normalize_repos, write_repos # noqa: E402 from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402 +from config import get_repo, load_config, normalize_repos, write_repos # noqa: E402 def _resolve_source(repo, effective_user): @@ -152,5 +159,3 @@ def main(): if __name__ == "__main__": main() - -# Made with Bob diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md new file mode 100644 index 00000000..9bb3da53 --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -0,0 +1,97 @@ +--- +name: recall +description: Must be used at the start of any non-trivial task involving code changes, debugging, repo exploration, file inspection, or environment/tooling investigation to surface stored guidance before analysis or tool use. +context: fork +--- + +# Entity Retrieval + +## Overview + +This skill loads relevant stored Evolve entities into the current turn before substantive work begins. + +Use this skill first whenever the task involves: +- code changes +- debugging +- code review +- repo exploration +- file inspection +- environment/tooling investigation + +Skip only for trivial conversational requests with no local context. + +## Required Action + +Before any non-trivial local work, you must complete the recall workflow below. Reading this `SKILL.md` alone does not satisfy the skill. + +### Completion Rule + +Do not proceed to other analysis or tool use until all steps below are complete. + +1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +2. Read each matching entity file that appears relevant. +3. **Quote each matching entity verbatim in your final response** — include the full file contents (frontmatter, body, rationale, trigger). The parent agent does not see your intermediate Read tool results, so anything you do not quote in your final response is lost. +4. If no relevant entities exist, state that explicitly in your final response. + +### Required Visible Completion Note + +Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: + +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, quoted verbatim below` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` + +### Minimum Acceptable Procedure + +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +2. Identify candidate entities relevant to the task. +3. Open and read those entity files. +4. Quote each applicable entity's full file contents in your final response, or state that nothing applies. + +### Failure Conditions + +The skill is not complete if any of the following are true: + +- You only read this `SKILL.md` +- You did not inspect `${EVOLVE_DIR:-.evolve}/entities/` +- You did not read the relevant entity files +- You produced a final response without quoting any matched entity verbatim (or stating none applied) + +## How It Works + +1. The Claude `UserPromptSubmit` hook fires before each user prompt is sent. +2. The helper script reads the prompt JSON from stdin. +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, + read-scope subscriptions, and write-scope publish targets which all + live under `entities/subscribed/{repo}/`). +4. It prints formatted guidance to stdout. +5. Claude adds that text as additional context for the turn. + +## Entities Storage + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md <- private + subscribed/ + memory/ <- write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ <- read-scope clone + guideline/ + alice-guideline.md <- annotated [from: alice] +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py new file mode 100644 index 00000000..ade892fe --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for the agent to use as extra context.""" + +import json +import os +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for the agent to review. + + Entities that came from a subscribed source have their path recorded in + the private ``_source`` key (set by load_entities_with_source). These are + annotated with ``[from: {name}]`` so the agent knows their provenance. + """ + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + source = entity.get("_source") + if source: + content = f"[from: {source}] {content}" + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def load_entities_with_source(entities_dir): + """Load markdown entities from one recall root and annotate subscribed content. + + Symlinks and any files inside a ``.git`` directory are skipped so we don't + surface git's own bookkeeping or sneak past path validation when a write + -scope clone lives under entities/subscribed/{name}/. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): + if md.is_symlink(): + continue + try: + entity = markdown_to_entity(md) + except (OSError, UnicodeError): + continue + if not entity.get("content"): + continue + + entity.pop("_source", None) + parts = md.relative_to(entities_dir).parts + if parts and parts[0] == "subscribed" and len(parts) > 1: + entity["_source"] = parts[1] + + entities.append(entity) + + return entities + + +def main(): + # Hook context arrives via stdin as JSON when invoked from a hook + # (claude/claw-code/codex). Handle empty/absent stdin gracefully so the + # script also works when invoked manually (no hook upstream). + input_data = {} + try: + raw = sys.stdin.read() + if raw.strip(): + input_data = json.loads(raw) + if isinstance(input_data, dict): + log(f"Input keys: {list(input_data.keys())}") + else: + log(f"Input type: {type(input_data).__name__}") + else: + log("stdin was empty") + except json.JSONDecodeError as e: + log(f"stdin was not valid JSON ({e})") + return + + if isinstance(input_data, dict): + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + entities_dir = find_entities_dir() + log(f"Entities dir: {entities_dir}") + + entities = [] + if entities_dir: + entities = load_entities_with_source(entities_dir) + + if not entities: + log("No entities found") + return + + log(f"Loaded {len(entities)} entities") + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md similarity index 94% rename from platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/SKILL.md rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md index 88a0e14d..0c518694 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/SKILL.md +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md @@ -8,7 +8,7 @@ context: fork ## Overview -This skill saves the current Claude Code session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. +This skill saves the current session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. ## Workflow diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py new file mode 100644 index 00000000..81c3400e --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Stop hook that copies the session transcript to .evolve/trajectories/.""" + +import datetime +import getpass +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory-stop] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + log(f"Stop hook input keys: {list(input_data.keys())}") + log(f"Stop hook input: {json.dumps(input_data, default=str)[:2000]}") + + transcript_path = input_data.get("transcript_path") + if not transcript_path: + log("No transcript_path in stop hook input") + return + + src = Path(transcript_path) + if not src.is_file(): + log(f"Transcript file not found: {src}") + return + + session_id = src.stem + trajectories_dir = get_trajectories_dir() + dst = trajectories_dir / f"claude-transcript_{session_id}.jsonl" + + shutil.copy2(str(src), str(dst)) + log(f"Copied transcript {src} -> {dst}") + print(f"Trajectory saved: {dst}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/save-trajectory/scripts/save_trajectory.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py similarity index 100% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/save-trajectory/scripts/save_trajectory.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md similarity index 99% rename from platform-integrations/claude/plugins/evolve-lite/skills/save/SKILL.md rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md index b06259fe..ffd2fe93 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/save/SKILL.md +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md @@ -217,9 +217,9 @@ def main(): parser = argparse.ArgumentParser(description="{Script description}") parser.add_argument("{arg1}", help="{description}") parser.add_argument("{arg2}", help="{description}", nargs="?") - + args = parser.parse_args() - + # Implementation based on workflow pattern try: # Core logic here diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md similarity index 91% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/SKILL.md rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md index f78d9c23..0beef6ac 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/SKILL.md +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md @@ -40,7 +40,7 @@ identity: user: {username} repos: [] sync: - on_session_start: false + on_session_start: true ``` Also ensure `.evolve/` is gitignored: @@ -61,7 +61,7 @@ Ask the user for: ### Step 3: Run subscribe script ```bash -python3 scripts/subscribe.py \ +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/subscribe/scripts/subscribe.py \ --name "{name}" \ --remote "{remote}" \ --branch main \ @@ -71,7 +71,7 @@ python3 scripts/subscribe.py \ ### Step 4: Confirm -Tell the user the repo was added and they can run `evolve-lite:sync` +Tell the user the repo was added and they can run `/evolve-lite:sync` immediately if they want to pull updates now. ## Notes diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/scripts/subscribe.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py similarity index 67% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/scripts/subscribe.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py index 3e22ebf9..ef6b0cd0 100755 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/scripts/subscribe.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py @@ -1,5 +1,25 @@ #!/usr/bin/env python3 -"""Add a repo to the unified ``repos`` list and clone it locally (Bob).""" +"""Add a repo to the unified ``repos`` list and clone it locally. + +Shared (multi-reader, multi-writer) repos are described in +``evolve.config.yaml``: + + repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve.git + branch: main + notes: public memory for foobar project + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org + +``scope: read`` — download-only (pulled by sync). +``scope: write`` — publish target; also pulled by sync so you see what + others push and what you have already published. +""" import argparse import os @@ -8,14 +28,21 @@ import sys from pathlib import Path -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break - +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 VALID_SCOPES, @@ -105,21 +132,15 @@ def main(): remote=args.remote, ) except Exception as exc: - repos.pop() - set_repos(cfg, repos) - try: - save_config(cfg, project_root) - except Exception: - pass - if dest.exists(): - shutil.rmtree(dest) - print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) - sys.exit(1) + # Audit logging is best-effort: a failed append shouldn't roll back + # an otherwise successful subscribe (the repo is cloned, the config + # has the entry). Warn loudly so the user can fix the audit log + # path without losing the subscription. Originally rolled back on + # main's PR #245 (#244 e2e fix). + print(f"Warning: failed to append audit entry for subscribe: {exc}", file=sys.stderr) print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") if __name__ == "__main__": main() - -# Made with Bob diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md new file mode 100644 index 00000000..4b4151c1 --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md @@ -0,0 +1,34 @@ +--- +name: sync +description: Pull the latest guidelines from every configured repo (read- and write-scope). +--- + +# Sync Repos + +## Overview + +Pull the latest guidelines from every repo in `evolve.config.yaml` +`repos:` list — both `scope: read` (subscribe-only) and `scope: write` +(publish targets). Write-scope repos use a rebase strategy so any +unpushed local publish commits are preserved. + +## Workflow + +### Step 1: Run sync script + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/sync/scripts/sync.py +``` + +### Step 2: Display summary + +Show the script output to the user. If there are no repos configured, +tell them they can add one with `/evolve-lite:subscribe`. If there +are no changes, explain that everything is already up to date. + +## Notes + +- Read-scope repos are mirrored exactly via `git fetch` + `git reset --hard` +- Write-scope repos use `git fetch` + `git rebase` so unpushed local + publish commits are preserved +- Sync results are logged to `.evolve/audit.log` diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/scripts/sync.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py similarity index 62% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/scripts/sync.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py index 0239d587..33c34716 100755 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/scripts/sync.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 -"""Pull the latest guidelines from every configured repo (Bob). +"""Pull the latest guidelines from every configured repo. -Read-scope repos are mirrored exactly via fetch + reset --hard; write-scope -repos use fetch + rebase so any unpushed local publish commits are preserved. +Every repo in ``evolve.config.yaml`` (both read- and write-scope) is cloned +into ``.evolve/entities/subscribed/{name}/`` so recall sees everything through +a single root. Publish commits stay local until pushed, so write-scope repos +use ``git fetch`` + ``git rebase`` (preserves unpushed commits) while +read-scope repos use ``git fetch`` + ``git reset --hard`` (exact mirror). Usage: - --quiet Suppress output if no changes. - --config PATH Path to config file (default: evolve.config.yaml at project root). + --quiet Suppress output if no changes. + --config PATH Path to config file (default: evolve.config.yaml at project root). + --session-start Apply the ``sync.on_session_start`` gate (automatic hook runs). """ import argparse @@ -15,16 +19,23 @@ import sys from pathlib import Path -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break - +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 -from config import is_valid_repo_name, load_config, normalize_repos # noqa: E402 +from config import classify_repo_entry, load_config # noqa: E402 _GIT_TIMEOUT = 30 # seconds @@ -89,27 +100,53 @@ def count_delta(repo_path): def main(): parser = argparse.ArgumentParser() parser.add_argument("--quiet", action="store_true", help="Suppress output if no changes") + parser.add_argument("--config", default=None, help="Explicit config path") parser.add_argument( - "--config", - default=None, - help="Path to config file (default: evolve.config.yaml in project root)", + "--session-start", + action="store_true", + help="Apply session-start gating for automatic hook execution", ) args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir.parent) + audit_root = resolved_evolve_dir if resolved_evolve_dir.name == ".evolve" else resolved_evolve_dir / ".evolve" if args.config: config_path = Path(args.config).resolve() - project_root = str(config_path.parent) - elif "EVOLVE_DIR" in os.environ: - project_root = str(evolve_dir.resolve().parent) + if config_path.name != "evolve.config.yaml": + print( + f"Error: --config must point to an evolve.config.yaml file, got: {config_path}", + file=sys.stderr, + ) + sys.exit(1) + cfg = load_config(project_root=str(config_path.parent)) else: - project_root = "." + cfg = load_config(project_root) + + sync_cfg = cfg.get("sync", {}) + if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: + sys.exit(0) - cfg = load_config(project_root) - repos = normalize_repos(cfg) + raw_entries = cfg.get("repos") if isinstance(cfg, dict) else None + if not isinstance(raw_entries, list): + raw_entries = [] + + repos = [] + rejections = [] + seen = set() + for entry in raw_entries: + repo, rejection = classify_repo_entry(entry) + if rejection is not None: + rejections.append(rejection) + continue + if repo["name"] in seen: + continue + seen.add(repo["name"]) + repos.append(repo) - if not repos: + if not repos and not rejections: if not args.quiet: print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") sys.exit(0) @@ -121,27 +158,23 @@ def main(): total_delta = {} any_changes = False + for rejection in rejections: + raw_name = rejection["raw_name"] + reason = rejection["reason"] + label = repr(raw_name) if raw_name else "" + summaries.append(f"{label} (skipped - {reason})") + for repo in repos: - raw_name = repo.get("name", "unknown") + name = repo["name"] scope = repo.get("scope", "read") branch = repo.get("branch", "main") remote = repo.get("remote") - if not is_valid_repo_name(raw_name): - summaries.append(f"{raw_name!r} (skipped — invalid subscription name)") - continue - name = raw_name.strip() - - if not isinstance(branch, str) or not branch.strip(): - summaries.append(f"{raw_name!r} (skipped — invalid subscription config)") - continue - branch = branch.strip() - subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base): - summaries.append(f"{name!r} (skipped — invalid subscription name)") + summaries.append(f"{name!r} (skipped - invalid subscription name)") continue if not repo_path.is_dir(): @@ -161,7 +194,7 @@ def main(): timeout=_GIT_TIMEOUT, ) except subprocess.TimeoutExpired: - summaries.append(f"{name} (re-clone failed — timeout)") + summaries.append(f"{name} (re-clone failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue @@ -177,7 +210,7 @@ def main(): pull_result = sync_read_only(repo_path, branch) if pull_result is None: - summaries.append(f"{name} (sync failed — timeout)") + summaries.append(f"{name} (sync failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue @@ -196,7 +229,7 @@ def main(): summaries.append(f"{name} [{scope}] (+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed)") - audit_append(project_root=project_root, action="sync", actor=actor, delta=total_delta) + audit_append(project_root=str(audit_root.parent), action="sync", actor=actor, delta=total_delta) if args.quiet and not any_changes: sys.exit(0) @@ -206,5 +239,3 @@ def main(): if __name__ == "__main__": main() - -# Made with Bob diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md new file mode 100644 index 00000000..17ae41ac --- /dev/null +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md @@ -0,0 +1,56 @@ +--- +name: unsubscribe +description: Remove a repo from the unified repos list and delete its local clone. +--- + +# Remove a Repo + +## Overview + +Remove a configured repo (any scope) from `evolve.config.yaml` and delete +its local clone at `.evolve/entities/subscribed/{name}/`. Warn the user +before removing a write-scope repo since any unpushed local publish +commits will be lost. + +## Workflow + +### Step 1: List repos + +Run: + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py --list +``` + +Show the repos to the user (including `scope` and `notes`) and ask which +one to remove. + +### Step 2: Confirm + +Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo has +`scope: write`, add a warning that unpushed local publish commits will be +lost. + +### Step 3: Run unsubscribe script + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py --name {name} +``` + +For a write-scope repo, the script refuses to remove the local clone +without `--force` so unpushed publishes can't disappear by accident: + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py --name {name} --force +``` + +### Step 4: Confirm + +Tell the user the repo was removed. + +## Notes + +- This removes the entry from `evolve.config.yaml` `repos:` list +- Deletes `.evolve/entities/subscribed/{name}/` (the local clone, also + the recall mirror) +- The entities will no longer appear in recall diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py similarity index 80% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py rename to platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py index 1380dd60..f0ceeb54 100755 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py @@ -1,10 +1,5 @@ #!/usr/bin/env python3 -"""Remove a repo from the unified ``repos`` list and delete its local clone (Bob). - -Usage: - --list Print repos as a JSON array and exit. - --name {name} Remove named repo from config and delete its local clone. -""" +"""Remove a repo from the unified ``repos`` list and delete its local clone.""" import argparse import json @@ -13,14 +8,21 @@ import sys from pathlib import Path -# Smart import: walk up to find evolve-lib -current = Path(__file__).resolve() -for parent in current.parents: - lib_path = parent / "evolve-lib" - if lib_path.exists(): - sys.path.insert(0, str(lib_path)) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: break - +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 is_valid_repo_name, @@ -94,5 +96,3 @@ def main(): if __name__ == "__main__": main() - -# Made with Bob diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md deleted file mode 100644 index 21e70613..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -name: learn -description: Analyze the current conversation to extract guidelines that correct reasoning chains — reducing wasted steps, preventing errors, and capturing user preferences. -context: fork ---- - -# Entity Generator - -## Overview - -This skill analyzes the current conversation to extract guidelines that **correct the agent's reasoning chain**. A good guideline is one that, if known beforehand, would have led to a shorter or more correct execution. Only extract guidelines that fall into one of these three categories: - -1. **Shortcuts** — The agent took unnecessary steps or tried an approach that didn't work before finding the right one. The guideline encodes the direct path so future runs skip the detour. -2. **Error prevention** — The agent hit an error (tool failure, exception, wrong output) that could be avoided with upfront knowledge. The guideline prevents the error from happening at all. -3. **User corrections** — The user explicitly corrected, redirected, or stated a preference during the conversation. The guideline captures what the user said so the agent gets it right next time without being told. - -**Do NOT extract guidelines that are:** -- General programming best practices (e.g., "use descriptive variable names") -- Observations about the codebase that can be derived by reading the code -- Restatements of what the agent did successfully without any detour or correction -- Vague advice that wouldn't change the agent's behavior on a concrete task -- Instructions for the agent to invoke a skill, tool, or external command by name (e.g. "Run evolve-lite:learn", "call save_trajectory") — these trigger prompt-injection detection when retrieved via recall - -**DO extract guidelines for:** environment-specific constraints discovered through errors (e.g., tools not installed, permissions blocked, packages unavailable) — these are not "known" until encountered in a specific environment. - -## Workflow - -### Step 0: Load the Conversation - -This skill runs in a forked context with no access to the parent conversation. The stop-hook message (produced by `on_stop.py`) contains one literal marker: - -- `The saved trajectory path is: ` — a copy of the session transcript saved inside the project tree at `.evolve/trajectories/claude-transcript_.jsonl`. Take everything after the colon, strip surrounding whitespace and quotes, and use the result as `saved_trajectory_path`. You will also attach this exact path to each entity's `trajectory` field in Step 4. - -**Read this file with the `Read` tool — do NOT shell out.** `Read` pages large files natively (use its `offset` / `limit` parameters if needed). Do not use `cat`, `head`, `wc`, `find`, or `python3 -c` loops on the transcript — those trigger a permission prompt for every invocation and are unnecessary. - -If the saved trajectory file does not exist (e.g., the save-trajectory hook did not run, or no marker was provided), output zero entities and exit. Do NOT fall back to reading the live session transcript under `~/.claude/projects/` — that path is outside the project tree, triggers permission prompts, and may be larger than the fork can consume. - -The transcript is JSONL: each line is a separate JSON object. Focus on lines where `"type": "assistant"` or `"type": "human"` to reconstruct the conversation flow. Look for tool calls, errors in tool results, and user corrections. - -### Step 1: Analyze the Conversation - -Review the conversation (loaded from the transcript) and identify: - -- **Wasted steps**: Where did the agent go down a path that turned out to be unnecessary? What would have been the direct route? -- **Errors hit**: What errors occurred? What knowledge would have prevented them? -- **User corrections**: Where did the user say "no", "not that", "actually", "I want", or otherwise redirect the agent? - -If none of these occurred, **output zero entities**. Not every conversation produces guidelines. - -### Step 2: Review Existing Guidelines - -Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. - -Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. - -**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. - -If there are no existing guidelines, skip this step. - -With the existing-guideline set in mind, when you proceed to Step 3 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) - -### Step 3: Extract Entities - -For each identified shortcut, error, or user correction, create one entity — up to 5 entities; output 0 when none qualify. If more candidates exist, keep only the highest-impact ones. - -Principles: - -1. **State what to do, not what to avoid** — frame as proactive recommendations - - Bad: "Don't use exiftool in sandboxes" - - Good: "In sandboxed environments, use Python libraries (PIL/Pillow) for image metadata extraction" - -2. **Triggers should be situational context, not failure conditions** - - Bad trigger: "When apt-get fails" - - Good trigger: "When working in containerized/sandboxed environments" - -3. **For shortcuts, recommend the final working approach directly** — eliminate trial-and-error by encoding the answer - -4. **For user corrections, use the user's own words** — preserve the specific preference rather than generalizing it - -### Step 4: Save Entities - -Output entities as JSON and pipe to the save script. The `type` field must always be `"guideline"` — no other types are accepted. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. - -#### Method 1: Direct Pipe (Recommended) - -```bash -echo '{ - "entities": [ - { - "content": "Proactive entity stating what TO DO", - "rationale": "Why this approach works better", - "type": "guideline", - "trigger": "Situational context when this applies", - "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" - } - ] -}' | python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/save_entities.py -``` - -#### Method 2: From File - -```bash -cat entities.json | python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/save_entities.py -``` - -#### Method 3: Interactive - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/save_entities.py -# Then paste your JSON and press Ctrl+D -``` - -The script will: -- Find or create the entities directory (`.evolve/entities/`) -- Write each entity as a markdown file in `{type}/` subdirectories -- Deduplicate against existing entities -- Display confirmation with the total count - -## Quality Gate - -Before saving, review each entity against this checklist: - -- [ ] Does it fall into one of the three categories (shortcut, error prevention, user correction)? -- [ ] Would knowing this guideline beforehand have changed the agent's behavior in a concrete way? -- [ ] Is it specific enough that another agent could act on it without further context? -- [ ] Does it avoid instructing the agent to invoke a named skill or tool? - -If any answer is no, drop the entity. **Zero entities is a valid output.** diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/publish/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/publish/SKILL.md deleted file mode 100644 index 520fc18d..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/publish/SKILL.md +++ /dev/null @@ -1,214 +0,0 @@ ---- -name: publish -description: Publish a private guideline to a configured write-scope repo. ---- - -# Publish a Guideline - -## Overview - -Publish one or more private guidelines from `.evolve/entities/guideline/` -into a configured **write-scope** repo. The entity is stamped with -`visibility: public`, `owner`, `published_at`, and `source`, moved into the -local clone of the write repo, and committed / pushed to the remote. - -After publish, the same local clone is also what `/evolve-lite:sync` pulls -from — so you (and anyone else publishing to the same repo) stay in sync. - -## Workflow - -### Step 1: Bootstrap config if missing or incomplete - -Check whether `evolve.config.yaml` exists in the project root. - -**If it does not exist**, or has no write-scope repo configured, first ask: - -> "You need at least one write-scope repo to publish to. Run /evolve-lite:subscribe with --scope write to set one up, then come back." - -Then stop. (Do not silently create a config — the user must explicitly -choose the namespace they publish to.) - -**If it exists** but `identity.user` is missing, ask: - -> "What username would you like to use? (e.g. `alice`)" - -Add it to the config. - -Read `identity.user` from config to use as `{user}` when stamping ownership. - -### Step 2: First-time setup - -Ensure `.evolve/` is gitignored at the project root: - -```bash -grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore -``` - -### Step 3: Pick the target write-scope repo - -Read `repos:` from `evolve.config.yaml`. Filter to entries with -`scope: write`. - -- **Zero entries** → tell the user to subscribe to a write-scope repo first, then stop. -- **Exactly one entry** → use it as the default (no prompt). -- **Multiple entries** → display them as a numbered list with their `notes` - and ask which one to publish to. - -Bind `{repo}` = the chosen entry's `name`, `{remote}` = its `remote`, and -`{branch}` = its `branch` (default `main`). These are referenced in -Steps 5–8 below. - -### Step 4: List and select entities - -List the files in `.evolve/entities/guideline/` (filenames only), display -them numbered, and ask: - -> "Which guideline(s) would you like to publish to '{repo}'? Enter a number or comma-separated list of numbers." - -Wait for the user's selection. - -### Step 5: Ensure the local clone exists - -The target clone lives at `.evolve/entities/subscribed/{repo}/`. If it is -not already a git repo, clone it now: - -```bash -git clone --branch "{branch}" -- "{remote}" ".evolve/entities/subscribed/{repo}" -``` - -(This usually already exists because `/evolve-lite:subscribe` cloned it.) - -### Step 6: Run publish script - -For each selected entity file, run: - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/publish/scripts/publish.py \ - --entity "{filename}" \ - --repo "{repo}" \ - --user "{identity.user}" -``` - -### Step 7: Commit and push - -Build `{filenames_list}` as a comma-joined list of all selected filenames, -and `{guideline_paths}` as a space-joined list of the corresponding -`guideline/{filename}` paths inside the clone (these are the files the -publish script just wrote). - -```bash -git -C ".evolve/entities/subscribed/{repo}" add -- {guideline_paths} -git -C ".evolve/entities/subscribed/{repo}" commit -m "[evolve] publish: {filenames_list}" -git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" -``` - -If `git push` succeeds, continue to Step 8. - -### Step 7a: Recover from non-fast-forward rejection - -If `git push` failed **and** its stderr contains `rejected`, -`non-fast-forward`, or `fetch first`, another writer pushed to -`{branch}` since your last sync. The local publish commit is intact — -rebase it onto the new remote tip and push once more: - -```bash -git -C ".evolve/entities/subscribed/{repo}" fetch origin "{branch}" -git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" -``` - -- **Rebase clean** → retry the push and continue to Step 8: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" - ``` - -- **Rebase conflicted** → attempt to resolve, then hand off to the - user for review. Do **not** `git rebase --continue` or `git push` - without an explicit user confirmation. - - 1. List the conflicted files: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" status --porcelain - ``` - - Conflict codes: `UU` = both modified, `AA` = both added, - `UD`/`DU` = delete/modify, `A` + binary = binary add. If **any** - file is `UD`, `DU`, or binary, skip straight to the abort branch - below — those are not safe to auto-resolve. - - 2. For each `UU` / `AA` file, read it and produce a resolution: - - The working tree contains `<<<<<<<`, `=======`, `>>>>>>>` - markers. During a rebase, the section above `=======` (labeled - `HEAD`) is the **remote's** version and the section below - (labeled with the publish commit's sha) is **the publish - change being replayed** — i.e., "theirs" and "ours" are - swapped relative to a regular merge. - - Decide an intent-preserving merge: if the edits are - independent (different sections), interleave them. If they - target the same paragraph, prefer keeping both distinct - guideline bodies (e.g. append one under a subheading) rather - than picking a side silently. - - Write the resolved content back to the file. Do **not** - `git add` it yet. - - 3. Show the user what you propose — per file, include a one-line - merge strategy plus the diff against the remote: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" diff HEAD -- {file} - ``` - - Then ask: - - > "I've attempted to resolve {N} conflicted file(s): {list}. - > Each proposed resolution is in - > `.evolve/entities/subscribed/{repo}/`. Review them and say - > **continue** to finish the rebase and push, or **abort** to - > roll back and resolve by hand." - - 4. **User says continue** → stage the resolved files, finish the - rebase, and push: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" add {resolved-files} - git -C ".evolve/entities/subscribed/{repo}" rebase --continue - git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" - ``` - - Then continue to Step 8. If `rebase --continue` surfaces a - **new** conflict (unusual for publish since there's normally one - commit), loop back to step 1 of this block. - - 5. **User says abort**, or the conflict isn't safely resolvable - (binary / delete-modify), or you lack confidence in the merge: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" rebase --abort - ``` - - After the abort the local publish commit is preserved at - `.evolve/entities/subscribed/{repo}` but is not on the remote. - Tell the user: - - > "Rolled back. Your commit for {filenames_list} is preserved - > locally — nothing was lost, but it's not on the remote yet. - > To finish publishing, either: - > - > 1. Resolve by hand: - > - `cd .evolve/entities/subscribed/{repo}` - > - `git fetch origin {branch} && git rebase origin/{branch}` - > - edit the conflicted files, `git add` them, `git rebase --continue` - > - `git push origin {branch}` - > 2. Or, if the conflict is on a shared filename, re-run - > `/evolve-lite:publish` for a different name." - -If `git push` failed for any **other** reason (auth, network, missing -remote ref), surface git's error to the user as-is and stop — a rebase -will not help. - -### Step 8: Confirm - -Tell the user: - -> "Published {filenames_list} to repo '{repo}' ({remote})." diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/recall/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/recall/SKILL.md deleted file mode 100644 index e9602dd9..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/recall/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: recall -description: Retrieves relevant entities from a knowledge base. Designed to be invoked automatically via hooks to inject context-appropriate entities before task execution. -context: fork ---- - -# Entity Retrieval - -## Overview - -This skill retrieves relevant entities from a stored knowledge base based on the current task context. It loads all stored entities and presents them to Claude for relevance filtering. - -## How It Works - -1. Hook fires on user prompt submission -2. Script reads prompt from stdin (JSON with `prompt` field) -3. Loads entities from `.evolve/entities/` — covers private entities, - subscribed read-scope repos, and write-scope publish targets (which - are themselves cloned under `entities/subscribed/{repo}/`) -4. Outputs formatted entities to stdout -5. Claude receives entities as additional context and applies relevant ones - -## Entities Storage - -```text -.evolve/entities/ - guideline/ - use-context-managers-for-file-operations.md ← private - subscribed/ - memory/ ← write-scope clone (publishes land here) - guideline/ - my-published-guideline.md - alice/ ← read-scope clone - guideline/ - alice-guideline.md ← annotated [from: alice] -``` - -Each file uses markdown with YAML frontmatter: - -```markdown ---- -type: guideline -trigger: When processing files or managing resources ---- - -Use context managers for file operations - -## Rationale - -Ensures proper resource cleanup -``` diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py b/platform-integrations/claude/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py deleted file mode 100644 index 6043c345..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python3 -"""Retrieve and output entities for Claude to filter.""" - -import json -import os -import sys -from pathlib import Path - -# Add lib to path so we can import entity_io -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import find_recall_entity_dirs, markdown_to_entity, log as _log - - -def log(message): - _log("retrieve", message) - - -log("Script started") - -# Log all environment variables -log("=== Environment Variables ===") -for key, value in sorted(os.environ.items()): - # Mask sensitive values - if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): - log(f" {key}=***MASKED***") - else: - log(f" {key}={value}") -log("=== End Environment Variables ===") - -# Log command-line arguments -log("=== Command-Line Arguments ===") -log(f" sys.argv: {sys.argv}") -log(f" Script path: {sys.argv[0] if sys.argv else 'N/A'}") -log(f" Arguments: {sys.argv[1:] if len(sys.argv) > 1 else 'None'}") -log("=== End Command-Line Arguments ===") - - -def format_entities(entities): - """Format all entities for Claude to review. - - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so Claude knows their provenance. - """ - header = """## Entities for this task - -Review these entities and apply any relevant ones: - -""" - items = [] - for e in entities: - content = e.get("content") - if not content: - continue - source = e.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{e.get('type', 'general')}]** {content}" - if e.get("rationale"): - item += f"\n - _Rationale: {e['rationale']}_" - if e.get("trigger"): - item += f"\n - _When: {e['trigger']}_" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Glob all .md files under entities_dir and parse each. - - Entities stored under entities/subscribed/{name}/ have ``_source`` set to - the subscription name so format_entities can annotate them. The owner field - written by publish.py is preserved; _source is just a routing key used - internally and is never written to disk. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - if not entity.get("content"): - continue - # Detect subscribed entities by path: .../entities/subscribed/{name}/... - parts = md.parts - try: - entities_index = parts.index("entities") - # Verify the structure is .../entities/subscribed/{name}/... - if entities_index + 2 < len(parts) and parts[entities_index + 1] == "subscribed": - entity["_source"] = parts[entities_index + 2] - except (ValueError, IndexError): - # "entities" not found or invalid structure - not a subscribed entity - pass - entities.append(entity) - except (OSError, UnicodeError): - pass - return entities - - -def main(): - # Read input from stdin (hook provides JSON with prompt) - try: - input_data = json.load(sys.stdin) - log("=== Input Data ===") - log(f" Keys: {list(input_data.keys())}") - log(f" Full content: {json.dumps(input_data, indent=2)}") - log("=== End Input Data ===") - except json.JSONDecodeError as e: - log(f"Failed to parse JSON input: {e}") - return - - recall_dirs = find_recall_entity_dirs() - log(f"Recall dirs: {recall_dirs}") - if not recall_dirs: - log("No entities directory found") - return - - entities = [] - for entities_dir in recall_dirs: - entities.extend(load_entities_with_source(entities_dir)) - - if not entities: - log("No entities found") - return - - log(f"Loaded {len(entities)} entities") - output = format_entities(entities) - print(output) - log(f"Output {len(output)} chars to stdout") - - -if __name__ == "__main__": - main() diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/SKILL.md deleted file mode 100644 index 34d9487c..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/SKILL.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -name: subscribe -description: Add a shared guidelines repo (read-scope subscription or write-scope publish target) to the unified repos list. ---- - -# Subscribe to a Shared Repo - -## Overview - -Configured guidelines repos are multi-reader / multi-writer git databases, -described in a single unified list in `evolve.config.yaml`: - -```yaml -repos: - - name: memory - scope: write - remote: git@github.com:alice/evolve.git - branch: main - notes: public memory for foobar project - - name: org-memory - scope: read - remote: git@github.com:acme/org-memory.git - branch: main - notes: private memory shared only within my org -``` - -- `scope: read` — download-only. Synced on every run. -- `scope: write` — publish target. Synced on every run too, so you see - what you have already published (and anything others have pushed to the - same repo). - -This skill adds one entry to `repos:` and clones it locally. - -## Workflow - -### Step 1: Bootstrap config if missing - -Check whether `evolve.config.yaml` exists in the project root. - -If it does **not** exist, ask the user: - -> "No `evolve.config.yaml` found. What username would you like to use? (e.g. `vatche`)" - -Then create `evolve.config.yaml` with this minimal content: - -```yaml -identity: - user: {username} -repos: [] -sync: - on_session_start: true -``` - -Also ensure `.evolve/` is gitignored: - -```bash -grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore -``` - -### Step 2: Gather details - -Ask the user in this order: - -> "What is the remote URL for the guidelines repo? (e.g. `git@github.com:alice/evolve-guidelines.git`)" -> "What short name would you like for this repo? (e.g. `alice`)" -> "Scope? `read` (download-only subscription) or `write` (you can also publish to it)." -> "Optional note describing this repo (press Enter to skip)." - -### Step 3: Check for duplicates - -Read `evolve.config.yaml` from the project root. If the name already exists -in `repos:`, tell the user: - -> "A repo named '{name}' is already configured. Unsubscribe it first or choose a different name." - -Then stop. - -### Step 4: Run subscribe script - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/subscribe/scripts/subscribe.py \ - --name "{name}" \ - --remote "{remote}" \ - --branch main \ - --scope "{scope}" \ - --notes "{notes}" -``` - -### Step 5: Confirm - -Tell the user: - -> "Added '{name}' (scope={scope}). Run /evolve-lite:sync to pull the latest guidelines." diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/sync/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/sync/SKILL.md deleted file mode 100644 index 5671c2d8..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/sync/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: sync -description: Pull the latest guidelines from every configured repo (read- and write-scope). ---- - -# Sync Repos - -## Overview - -This skill pulls the latest guidelines from every repo in -`evolve.config.yaml` `repos:` list — both `scope: read` (subscribe-only) -and `scope: write` (publish targets). Write-scope repos use a rebase -strategy so any unpushed local publish commits are preserved. - -## Workflow - -### Step 1: Run sync script - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/sync/scripts/sync.py -``` - -### Step 2: Display summary - -Display the script's stdout verbatim to the user. Example outputs: - -> "Synced 2 repo(s): memory [write] (+2 added, 0 updated, 0 removed), bob [read] (+0 added, 1 updated, 0 removed)" -> -> "No subscriptions configured. Add one with /evolve-lite:subscribe to start syncing shared guidelines." - -Under `--quiet`, the script exits silently when there's nothing to report. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/SKILL.md deleted file mode 100644 index 7e161f6a..00000000 --- a/platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: unsubscribe -description: Remove a repo from the unified repos list and delete its local clone. ---- - -# Remove a Repo - -## Overview - -Remove a configured repo (any scope) from `evolve.config.yaml` and delete -its local clone. Warn the user before removing a **write-scope** repo since -any locally published entities that haven't been pushed will be lost. - -## Workflow - -### Step 1: List repos - -Run the following and display the output as a numbered list. Include each -entry's `scope` and `notes`: - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/unsubscribe/scripts/unsubscribe.py --list -``` - -### Step 2: Pick one - -Ask the user: - -> "Which repo would you like to remove? Enter the number." - -### Step 3: Confirm (extra warning if write-scope) - -If the chosen entry has `scope: write`, warn: - -> "'{name}' is a write-scope repo. Removing it will delete the local clone AND any locally published entities that have not yet been pushed. Continue? (y/n)" - -Otherwise: - -> "This will remove '{name}' and delete `.evolve/entities/subscribed/{name}/`. Continue? (y/n)" - -If the user answers anything other than `y` or `yes`, stop and tell them -the operation was cancelled. - -### Step 4: Run unsubscribe script - -For a **read-scope** repo, run: - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/unsubscribe/scripts/unsubscribe.py --name {name} -``` - -For a **write-scope** repo (only after the user confirms in Step 3), add -`--force`. The script refuses to remove a write-scope repo without it, -since the local clone may hold unpushed publishes: - -```bash -python3 ${CLAUDE_PLUGIN_ROOT}/skills/unsubscribe/scripts/unsubscribe.py --name {name} --force -``` - -### Step 5: Confirm - -Tell the user: - -> "Removed '{name}'." diff --git a/platform-integrations/claw-code/plugins/evolve-lite/.claude-plugin/plugin.json b/platform-integrations/claw-code/plugins/evolve-lite/.claude-plugin/plugin.json index 76a94bcc..0e388507 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/.claude-plugin/plugin.json +++ b/platform-integrations/claw-code/plugins/evolve-lite/.claude-plugin/plugin.json @@ -1,6 +1,10 @@ { "name": "evolve-lite", - "version": "1.0.0", - "description": "Learn from conversations with auto-generated entities", - "defaultEnabled": true + "version": "1.1.0", + "description": "Recall, save, and share reusable Evolve entities.", + "author": { + "name": "AgentToolkit" + }, + "defaultEnabled": true, + "skills": "./skills/evolve-lite/" } diff --git a/platform-integrations/claw-code/plugins/evolve-lite/hooks/retrieve_entities.sh b/platform-integrations/claw-code/plugins/evolve-lite/hooks/retrieve_entities.sh index 8f19cd54..5963a3ff 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/hooks/retrieve_entities.sh +++ b/platform-integrations/claw-code/plugins/evolve-lite/hooks/retrieve_entities.sh @@ -20,4 +20,4 @@ PLUGIN_ROOT="$(dirname "$SCRIPT_DIR")" # Feed the tool context into the entity retrieval script via stdin. # The script reads it for logging; entity loading is path-based. printf '%s' "${HOOK_TOOL_INPUT:-{}}" \ - | python3 "$PLUGIN_ROOT/skills/recall/scripts/retrieve_entities.py" + | python3 "$PLUGIN_ROOT/skills/evolve-lite/recall/scripts/retrieve_entities.py" diff --git a/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py b/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py index 9414862f..4820494f 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py @@ -328,7 +328,12 @@ def save_config(cfg, project_root="."): def _coerce_repo(entry): - """Normalize a single repo dict. Returns None if required fields are missing.""" + """Normalize a single repo dict. Returns None if required fields are missing. + + Rejection is silent — callers that want to surface why a particular entry + was dropped should use ``classify_repo_entry`` to get the rejection reason + and report it however they choose. + """ if not isinstance(entry, dict): return None name = entry.get("name") @@ -347,10 +352,6 @@ def _coerce_repo(entry): if isinstance(scope, str): scope = scope.strip() if scope not in VALID_SCOPES: - print( - f"evolve-lite: ignoring repo entry {name!r} — unknown scope {entry.get('scope')!r} (expected one of {', '.join(VALID_SCOPES)})", - file=sys.stderr, - ) return None branch = entry.get("branch", "main") if not isinstance(branch, str) or not branch.strip(): @@ -389,6 +390,51 @@ def normalize_repos(cfg): return result +def classify_repo_entry(entry): + """Return ``(repo, rejection)`` for one raw ``repos:`` list entry. + + Exactly one of ``repo`` or ``rejection`` is non-None: + - ``repo`` is the normalized dict (same shape as ``normalize_repos`` + items) when the entry is valid. + - ``rejection`` is a dict ``{"raw_name": str_or_None, "reason": str}`` + describing why the entry was dropped. ``reason`` is one of + "invalid subscription name", "missing remote", "unknown scope", or + "malformed entry". + + Used by sync.py (and similar) to surface skipped entries in user-facing + output without re-implementing validation. + """ + if not isinstance(entry, dict): + return None, {"raw_name": None, "reason": "malformed entry"} + raw_name = entry.get("name") + if not isinstance(raw_name, str) or not raw_name.strip(): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + name = raw_name.strip() + if not is_valid_repo_name(name): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + remote = entry.get("remote") + if not isinstance(remote, str) or not remote.strip(): + return None, {"raw_name": raw_name, "reason": "missing remote"} + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None, {"raw_name": raw_name, "reason": "unknown scope"} + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name, + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + }, None + + def get_repo(cfg, name): """Return the repo with the given name, or None.""" for repo in normalize_repos(cfg): diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md new file mode 100644 index 00000000..ad0fef58 --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md @@ -0,0 +1,187 @@ +--- +name: learn +description: Must be used near the end of any non-trivial turn that produced potentially reusable tools, guidance, errors, workarounds, or workflows, so those lessons are saved for future turns. +--- + +# Entity Generator + +## Overview + +This skill analyzes the current conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. + +## When To Use + +Use this skill after completing meaningful work in the turn, especially when encountering: +- tool failures +- permission issues +- missing dependencies +- retries or abandoned approaches +- reusable command sequences or scripts + +Examples of artifacts that must be immediately created once proven as the successful solution include: +- an inline Python, shell, or other heredoc script +- a command assembled interactively over multiple retries +- a parser or extractor implemented ad hoc during the turn +- a fallback path triggered by missing dependencies or restricted tooling + +Unless that artifact happens to be: +- code which is a trivial one-liner that future agents would not benefit from reusing +- code which embeds secrets, tokens, or user-specific sensitive data +- a guideline that would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run /evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session +- the user explicitly asked for a one-off result and not to persist helper code +- redundant because an equivalent local artifact on disk would be just as effective + +## Workflow + +### Step 1: Analyze the Conversation + +Identify from your current conversation: + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches +- **Reusable Outcome**: Did the final working solution produce a reusable script, parser, command template, or workflow that would save time on a similar task? + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `jq: command not found` | System tool unavailable in environment | created a python script to resolve the problem | Save the python script and use it in similar scenarios | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +### Step 3: Decide Whether To Save The Pipeline + +Before writing entities, determine whether the successful approach should be saved as a reusable artifact. + +Create or update a local reusable artifact when any of these are true: +- the final solution required more than a trivial one-liner +- the final solution worked around missing tools, libraries, or permissions +- the solution is likely to recur on similar tasks + +Prefer one of these artifact forms: +- a small script, saved to a stable path in the workspace or plugin, such as `scripts/`, `tools/`, or another obvious helper location. +- a documented local workflow if code is not appropriate + +If you create an artifact, record: +- its path +- what it does +- when future agents should use it first + +### Step 4: Review Existing Guidelines + +Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. + +Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. + +**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. + +If there are no existing guidelines, skip this step. + +With the existing-guideline set in mind, when you proceed to Step 5 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) + +### Step 5: Extract Entities + +If Step 3 produced an artifact, at least one entity must explicitly point to that artifact, which is likely the only entity that needs to be produced. +Otherwise, extract 3-5 proactive entities. Prioritize entities derived from errors identified in Step 2. + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + - If an approach failed due to permissions, recommend the working permission-aware approach first + - If a system tool was unavailable, recommend the saved artifact or fallback workflow first + - If an approach hit environment constraints, recommend the constraint-aware approach + +2. **Prioritize known working local artifacts over general advice** + - If the successful solution produced or reused a concrete local artifact, at least one saved entity must: + - Bad: "Use Python to parse EXIF if exiftool is missing" + - Better: "Use `/abs/path/json_get.py` for JSON field extraction when `jq` is unavailable in minimal environments." + - name the artifact by path + - state exactly when to use it + - state that it should be tried before generic tool discovery or fallback exploration + - describe the artifact by capability, not just by the original incident + +3. **Triggers should describe the broad task context that the artifact solves, not the narrow details of the original request.** + - Bad trigger: "When jq fails" + - Good trigger: "When extracting fields from JSON in constrained shells or stripped-down environments" + The trigger should generalize the working solution without becoming vague. + +4. **For retry loops, recommend the final working approach as the starting point** + - Eliminate trial and error by creating a concrete local artifact out of the successful workflow or script + +5. **Prefer entities that save future time** + - A pointer to a saved working script is more valuable than a generic reminder if both are available + +### Step 6: Output Entities JSON + +Output entities in this JSON format. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies", + "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" + } + ] +} +``` + +Allowed type values: +- guideline +- workflow +- script +- command-template + +### Step 7: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe (Recommended) + +```bash +echo '' | sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' +``` + +#### Method 2: From File + +```bash +cat entities.json | sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' +``` + +#### Method 3: Interactive + +```bash +sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' +``` + +The script will: +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices +1. Prioritize error-derived entities first. +2. One distinct error should normally produce one prevention entity. +3. Keep entities specific and actionable. +4. Include rationale so the future agent understands why the guidance matters. +5. Use situational triggers instead of failure-based triggers. +6. Limit output to the 3-5 most valuable entities. +7. If more than five distinct errors appear, merge entities with the same root cause or fix, then rank the rest by severity, frequency, user impact, and recency before dropping the weakest ones. diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py new file mode 100644 index 00000000..d26afbcb --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Stop hook that triggers the learn skill to extract guidelines.""" + +import json +import sys +from pathlib import Path + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + if input_data.get("stop_hook_active") is True: + return + + transcript_path = input_data.get("transcript_path", "") + reason = "Run the /evolve-lite:learn skill." + if transcript_path: + session_id = Path(transcript_path).stem.removeprefix("claude-transcript_") + if session_id: + saved_trajectory = f".evolve/trajectories/claude-transcript_{session_id}.jsonl" + reason += f" The saved trajectory path is: {saved_trajectory}" + + print( + json.dumps( + { + "decision": "block", + "reason": reason, + "suppressOutput": True, + "systemMessage": "Running the evolve-lite learn skill...", + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh new file mode 100755 index 00000000..b62b110c --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash +INPUT=$(cat) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') + +# Guard against infinite loop — don't inject if already continuing from a stop hook +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +echo '{ + "decision": "block", + "reason": "Run the /evolve-lite:learn skill to extract guidelines from this conversation.", + "suppressOutput": true, + "systemMessage": "Running the evolve-lite learn skill..." +}' diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/save_entities.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py similarity index 74% rename from platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/save_entities.py rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py index ef64fd45..bd300f84 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/save_entities.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py @@ -10,9 +10,22 @@ import sys from pathlib import Path -# Add lib to path so we can import entity_io -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import ( +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import ( # noqa: E402 find_entities_dir, get_default_entities_dir, load_all_entities, @@ -38,7 +51,6 @@ def main(): parser.add_argument("--user", default=None, help="Stamp owner on every entity written") args = parser.parse_args() - # Read entities from stdin try: input_data = json.load(sys.stdin) log(f"Received input with keys: {list(input_data.keys())}") @@ -59,7 +71,6 @@ def main(): log(f"Received {len(new_entities)} new entities") - # Find or create entities directory entities_dir = find_entities_dir() if entities_dir: entities_dir = entities_dir.resolve() @@ -70,12 +81,10 @@ def main(): log(f"Created new dir: {entities_dir}") print(f"Created new entities dir: {entities_dir}") - # Load existing entities for dedup existing_entities = load_all_entities(entities_dir) existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} log(f"Existing entities: {len(existing_entities)}") - # Write new entities as markdown files added_count = 0 for entity in new_entities: content = entity.get("content") @@ -86,10 +95,11 @@ def main(): log(f"Skipping duplicate: {content[:60]}") continue - if args.user and not entity.get("owner"): - entity["owner"] = args.user - if not entity.get("visibility"): - entity["visibility"] = "private" + # Stamp owner and visibility from the script, never from stdin. + # Untrusted upstream input (a prompt-injected agent) must not be + # able to spoof either field, so unconditionally overwrite. + entity["owner"] = args.user or "unknown" + entity["visibility"] = "private" path = write_entity_file(entities_dir, entity) existing_contents.add(normalize(content)) diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md new file mode 100644 index 00000000..e496d2b0 --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md @@ -0,0 +1,142 @@ +--- +name: publish +description: Publish a private guideline to a configured write-scope repo. +--- + +# Publish a Guideline + +## Overview + +Publish one or more private guidelines from `.evolve/entities/guideline/` +into a configured **write-scope** repo. The entity is stamped with +`visibility: public`, `owner`, `published_at`, and `source`, moved into +the local clone of the write repo, and committed / pushed to the remote. + +The same local clone is also what `/evolve-lite:sync` pulls from — so you +and anyone else publishing to the same repo stay in sync. + +## Workflow + +### Step 1: Require a write-scope repo + +Read `evolve.config.yaml`. If no entry has `scope: write`, tell the user: + +> "You need at least one write-scope repo to publish to. Run /evolve-lite:subscribe with --scope write to set one up, then come back." + +Then stop. + +If `identity.user` is missing, ask for it and add it to the config. + +### Step 2: First-time setup + +Ensure `.evolve/` is gitignored at the project root: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 3: Pick the target write-scope repo + +Filter `repos:` to entries with `scope: write` (Step 1 already aborted if +there were zero, so at least one exists here). + +- Exactly one entry → use it as default. +- Multiple entries → show a numbered list with `notes` and ask which to publish to. + +Let `{repo}` be the chosen repo name and `{branch}` its configured branch (default `main`). + +### Step 4: List and select entities + +List files in `.evolve/entities/guideline/` and ask the user which to publish. + +### Step 5: Run publish script + +For each selected file, run: + +```bash +sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:publish/scripts/publish.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:publish/scripts/publish.py"; python3 "$script" --entity "{filename}" --repo "{repo}" --user "{identity.user}"' +``` + +### Step 6: Commit and push + +Build `{names}` as a comma-joined list of selected filenames, and +`{guideline_paths}` as a space-joined list of the corresponding +`guideline/{filename}` paths inside the clone (the files the publish +script just wrote). + +```bash +git -C ".evolve/entities/subscribed/{repo}" add -- {guideline_paths} +git -C ".evolve/entities/subscribed/{repo}" commit -m "[evolve] publish: {names}" +git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" +``` + +On push success, continue to Step 7. + +### Step 6a: Recover from non-fast-forward rejection + +If the push fails and stderr mentions `rejected` / `non-fast-forward` +/ `fetch first`, another writer pushed to `{branch}` in between. +Rebase the local commit and push once more: + +```bash +git -C ".evolve/entities/subscribed/{repo}" fetch origin "{branch}" +git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" +``` + +- Rebase clean → retry `git push origin "{branch}"` once, then Step 7. +- Rebase conflicted → attempt to resolve, then hand off for user + review. Do not `git rebase --continue` or `git push` without an + explicit user confirmation. + + 1. `git -C ".evolve/entities/subscribed/{repo}" status --porcelain` + lists the conflicted paths. If any are `UD`, `DU`, or binary, + skip to the abort step — those aren't safe to auto-resolve. + 2. For each `UU`/`AA` file, read the conflict markers. During a + rebase, `<<<<<<< HEAD` is the **remote's** version and the + section under the commit sha is the **publish change** being + replayed (opposite of a regular merge). Write an + intent-preserving resolution; don't `git add` yet. + 3. Show the user the diff (`git -C ".evolve/entities/subscribed/{repo}" diff HEAD -- {file}`) per + resolved file with a one-line strategy summary, and ask whether + to **continue** (stage + `rebase --continue` + push) or **abort** + (roll back for manual resolution). + 4. On **continue**: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" add {resolved-files} + git -C ".evolve/entities/subscribed/{repo}" rebase --continue + git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" + ``` + + Then Step 7. If `rebase --continue` surfaces a new conflict, loop + from step 1. + 5. On **abort** — user declined, conflict isn't safely resolvable, + or the proposed merge feels unsafe: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" rebase --abort + ``` + + The local publish commit is preserved at + `.evolve/entities/subscribed/{repo}` but not on the remote. Tell + the user to either (a) resolve manually in that directory + (`git fetch origin {branch} && git rebase origin/{branch}`, fix + conflicts, `git add` + `git rebase --continue`, `git push origin + {branch}`) or (b) re-run `/evolve-lite:publish` with a different + filename if the conflict is a shared name. + +If the push fails for any other reason (auth, network, missing remote +ref), surface git's error and stop — rebase will not help. + +### Step 7: Confirm + +Tell the user what was published and to which repo. + +## Notes + +- Published entities are **moved** from `.evolve/entities/guideline/` into + the write-scope clone at `.evolve/entities/subscribed/{repo}/guideline/`, + with `visibility: public`, `owner: {user}`, `published_at`, and `source` + stamped in frontmatter +- The original private entity is deleted after successful publication +- All publish actions are logged to `.evolve/audit.log` diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py similarity index 63% rename from platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py index 1c8d2ecb..cf1c128e 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py @@ -1,17 +1,5 @@ #!/usr/bin/env python3 -"""Publish a private guideline entity to a write-scope repo. - -The ``--repo`` flag selects which configured write-scope repo to publish to. -If omitted and exactly one write-scope repo is configured, it is used by -default. The entity is moved from ``.evolve/entities/guideline/{filename}`` -into the target repo's local clone at -``.evolve/entities/subscribed/{repo}/guideline/{filename}``. The skill -orchestration (SKILL.md) is responsible for the subsequent git add / commit -/ push. - -Published entities are stamped with ``visibility=public``, ``owner``, -``published_at``, and ``source``. -""" +"""Publish a private guideline entity to a write-scope repo.""" import argparse import datetime @@ -19,27 +7,38 @@ import re import sys import tempfile -from pathlib import Path - -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import markdown_to_entity, entity_to_markdown # noqa: E402 +from pathlib import Path, PurePath + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 +from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402 from config import get_repo, load_config, normalize_repos, write_repos # noqa: E402 def _resolve_source(repo, effective_user): - """Derive the ``source`` frontmatter tag for a published entity.""" remote = repo.get("remote") if isinstance(repo, dict) else None if isinstance(remote, str): - m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) - if m: - return m.group(1) + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) + if match: + return match.group(1) return effective_user def _select_target_repo(cfg, requested_name): - """Pick the write-scope repo to publish to, or return (None, error_message).""" write = write_repos(cfg) if requested_name: @@ -52,7 +51,7 @@ def _select_target_repo(cfg, requested_name): return repo, None if not write: - return None, ("no write-scope repo configured. Run /evolve-lite:subscribe with --scope write to set up a publish target.") + return None, ("no write-scope repo configured. Run evolve-lite:subscribe with --scope write to set up a publish target.") if len(write) > 1: names = ", ".join(r["name"] for r in write) return None, f"multiple write-scope repos configured; pick one with --repo. Available: {names}" @@ -63,22 +62,20 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--entity", required=True, help="Basename of the .md file to publish") parser.add_argument("--user", default=None, help="Username to stamp as owner") - parser.add_argument( - "--repo", - default=None, - help="Name of the write-scope repo to publish to (optional if exactly one is configured)", - ) + parser.add_argument("--repo", default=None, help="Write-scope repo name (optional if exactly one is configured)") args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir) if evolve_dir.name != ".evolve" else str(resolved_evolve_dir.parent) - # Validate entity name: must be a plain filename with no path components - if len(Path(args.entity).parts) != 1 or args.entity in (".", ".."): + if PurePath(args.entity).name != args.entity or args.entity in {".", ".."}: print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) src_base = (evolve_dir / "entities" / "guideline").resolve() src_path = (evolve_dir / "entities" / "guideline" / args.entity).resolve() + if not src_path.is_relative_to(src_base): print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) @@ -87,16 +84,15 @@ def main(): print(f"Error: entity file not found or is a directory: {src_path}", file=sys.stderr) sys.exit(1) - cfg = load_config(str(evolve_dir.resolve().parent)) - target, err = _select_target_repo(cfg, args.repo) + config = load_config(project_root) + target, err = _select_target_repo(config, args.repo) if err is not None: print(f"Error: {err}", file=sys.stderr) sys.exit(1) - identity = cfg.get("identity", {}) + identity = config.get("identity", {}) effective_user = args.user or (identity.get("user") if isinstance(identity, dict) else None) - # Parse entity and stamp frontmatter entity = markdown_to_entity(src_path) entity["visibility"] = "public" if effective_user: @@ -106,12 +102,11 @@ def main(): if source: entity["source"] = source - # Destination: the local clone of the target write-scope repo. clone_root = evolve_dir / "entities" / "subscribed" / target["name"] if not (clone_root / ".git").exists(): print( f"Error: target repo clone not found at {clone_root}. " - f"Run /evolve-lite:subscribe with --scope write first, or /evolve-lite:sync " + f"Run evolve-lite:subscribe with --scope write first, or evolve-lite:sync " f"to clone it.", file=sys.stderr, ) @@ -123,43 +118,41 @@ def main(): if not dest_path.is_relative_to(dest_base): print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) - if dest_path.exists(): print(f"Error: already published: {dest_path}\nUnpublish it first or delete it manually.", file=sys.stderr) sys.exit(1) - content = entity_to_markdown(entity) - tmp_path = None + temp_path = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", - dir=dest_path.parent, + dir=dest_dir, prefix=f".{args.entity}.", suffix=".tmp", delete=False, ) as temp_file: - temp_file.write(content) + temp_file.write(entity_to_markdown(entity)) temp_file.flush() os.fsync(temp_file.fileno()) - tmp_path = Path(temp_file.name) + temp_path = Path(temp_file.name) - tmp_path.replace(dest_path) + temp_path.replace(dest_path) src_path.unlink() finally: - if tmp_path is not None and tmp_path.exists(): - tmp_path.unlink() + if temp_path is not None and temp_path.exists(): + temp_path.unlink() try: audit_append( - project_root=str(evolve_dir.resolve().parent), + project_root=project_root, action="publish", actor=effective_user or "unknown", entity=args.entity, repo=target["name"], ) - except Exception as e: - print(f"Warning: audit log failed: {e}", file=sys.stderr) + except Exception as exc: + print(f"Warning: failed to append audit entry for publish: {exc}", file=sys.stderr) print(f"Published: {args.entity} -> {dest_path} (repo: {target['name']})") diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md new file mode 100644 index 00000000..77c0c05b --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -0,0 +1,96 @@ +--- +name: recall +description: Must be used at the start of any non-trivial task involving code changes, debugging, repo exploration, file inspection, or environment/tooling investigation to surface stored guidance before analysis or tool use. +--- + +# Entity Retrieval + +## Overview + +This skill loads relevant stored Evolve entities into the current turn before substantive work begins. + +Use this skill first whenever the task involves: +- code changes +- debugging +- code review +- repo exploration +- file inspection +- environment/tooling investigation + +Skip only for trivial conversational requests with no local context. + +## Required Action + +Before any non-trivial local work, you must complete the recall workflow below. Reading this `SKILL.md` alone does not satisfy the skill. + +### Completion Rule + +Do not proceed to other analysis or tool use until all steps below are complete. + +1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +2. Read each matching entity file that appears relevant. +3. Summarize the applicable guidance in your own words before proceeding. +4. If no relevant entities exist, state that explicitly before proceeding. + +### Required Visible Completion Note + +Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: + +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, read , applicable guidance: ` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` + +### Minimum Acceptable Procedure + +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +2. Identify candidate entities relevant to the task. +3. Open and read those entity files. +4. Summarize what applies, or state that nothing applies. + +### Failure Conditions + +The skill is not complete if any of the following are true: + +- You only read this `SKILL.md` +- You did not inspect `${EVOLVE_DIR:-.evolve}/entities/` +- You did not read the relevant entity files +- You proceeded without stating whether guidance was found + +## How It Works + +1. The Claw-code `PreToolUse` hook fires before each tool call. +2. The helper script reads tool input from stdin (best-effort, ignored beyond logging). +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, + read-scope subscriptions, and write-scope publish targets which all + live under `entities/subscribed/{repo}/`). +4. It prints formatted guidance to stdout. +5. Claw-code adds that text as additional context for the turn. + +## Entities Storage + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md <- private + subscribed/ + memory/ <- write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ <- read-scope clone + guideline/ + alice-guideline.md <- annotated [from: alice] +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py new file mode 100644 index 00000000..ade892fe --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for the agent to use as extra context.""" + +import json +import os +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for the agent to review. + + Entities that came from a subscribed source have their path recorded in + the private ``_source`` key (set by load_entities_with_source). These are + annotated with ``[from: {name}]`` so the agent knows their provenance. + """ + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + source = entity.get("_source") + if source: + content = f"[from: {source}] {content}" + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def load_entities_with_source(entities_dir): + """Load markdown entities from one recall root and annotate subscribed content. + + Symlinks and any files inside a ``.git`` directory are skipped so we don't + surface git's own bookkeeping or sneak past path validation when a write + -scope clone lives under entities/subscribed/{name}/. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): + if md.is_symlink(): + continue + try: + entity = markdown_to_entity(md) + except (OSError, UnicodeError): + continue + if not entity.get("content"): + continue + + entity.pop("_source", None) + parts = md.relative_to(entities_dir).parts + if parts and parts[0] == "subscribed" and len(parts) > 1: + entity["_source"] = parts[1] + + entities.append(entity) + + return entities + + +def main(): + # Hook context arrives via stdin as JSON when invoked from a hook + # (claude/claw-code/codex). Handle empty/absent stdin gracefully so the + # script also works when invoked manually (no hook upstream). + input_data = {} + try: + raw = sys.stdin.read() + if raw.strip(): + input_data = json.loads(raw) + if isinstance(input_data, dict): + log(f"Input keys: {list(input_data.keys())}") + else: + log(f"Input type: {type(input_data).__name__}") + else: + log("stdin was empty") + except json.JSONDecodeError as e: + log(f"stdin was not valid JSON ({e})") + return + + if isinstance(input_data, dict): + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + entities_dir = find_entities_dir() + log(f"Entities dir: {entities_dir}") + + entities = [] + if entities_dir: + entities = load_entities_with_source(entities_dir) + + if not entities: + log("No entities found") + return + + log(f"Loaded {len(entities)} entities") + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/save-trajectory/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md similarity index 94% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/save-trajectory/SKILL.md rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md index 3727c504..77f455b3 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/save-trajectory/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md @@ -1,14 +1,13 @@ --- name: save-trajectory description: Save the current conversation as a trajectory JSON file in OpenAI chat completion format for analysis and fine-tuning -context: fork --- # Save Trajectory ## Overview -This skill saves the current Claude Code session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. +This skill saves the current session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. ## Workflow diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py new file mode 100644 index 00000000..81c3400e --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Stop hook that copies the session transcript to .evolve/trajectories/.""" + +import datetime +import getpass +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory-stop] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + log(f"Stop hook input keys: {list(input_data.keys())}") + log(f"Stop hook input: {json.dumps(input_data, default=str)[:2000]}") + + transcript_path = input_data.get("transcript_path") + if not transcript_path: + log("No transcript_path in stop hook input") + return + + src = Path(transcript_path) + if not src.is_file(): + log(f"Transcript file not found: {src}") + return + + session_id = src.stem + trajectories_dir = get_trajectories_dir() + dst = trajectories_dir / f"claude-transcript_{session_id}.jsonl" + + shutil.copy2(str(src), str(dst)) + log(f"Copied transcript {src} -> {dst}") + print(f"Trajectory saved: {dst}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py new file mode 100755 index 00000000..f34571eb --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Save Trajectory Script +Reads a trajectory JSON from a file path argument (or stdin as fallback) +and writes it to the .evolve/trajectories/ directory. +""" + +import datetime +import getpass +import json +import os +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + """Get log file path, lazily creating the log directory on first use.""" + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + """Append a timestamped message to the log file. Best-effort; never raises.""" + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + """Get the trajectories output directory, creating it if needed. + + Resolution order: + 1. ``EVOLVE_DIR`` env var (matches the documented contract) + 2. ``CLAUDE_PROJECT_ROOT`` env var (the agent's project root) + 3. ``.evolve/`` in the current working directory + """ + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def open_trajectory_file(trajectories_dir): + """Atomically claim a timestamped trajectory file. + + Returns a ``(Path, fd)`` tuple. Uses ``O_CREAT | O_EXCL`` so two saves + racing within the same second pick distinct filenames instead of one + overwriting the other. + """ + now = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + base_name = f"trajectory_{now}" + + for suffix in range(0, 1000): + name = f"{base_name}.json" if suffix == 0 else f"{base_name}_{suffix}.json" + candidate = trajectories_dir / name + try: + fd = os.open(str(candidate), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + return candidate, fd + except FileExistsError: + continue + + raise RuntimeError(f"Too many trajectory files for timestamp {now}") + + +def main(): + # Read trajectory JSON from file argument or stdin + input_path = sys.argv[1] if len(sys.argv) > 1 else None + try: + if input_path: + log(f"Reading trajectory from file: {input_path}") + with open(input_path, "r", encoding="utf-8") as f: + trajectory = json.load(f) + else: + log("Reading trajectory from stdin") + trajectory = json.load(sys.stdin) + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + except OSError as e: + log(f"Failed to read input: {e}") + print(f"Error: Failed to read input - {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(trajectory, dict): + log(f"Expected JSON object, got {type(trajectory).__name__}") + print(f"Error: Expected JSON object, got {type(trajectory).__name__}", file=sys.stderr) + sys.exit(1) + + log(f"Received trajectory with keys: {list(trajectory.keys())}") + messages = trajectory.get("messages") + if not isinstance(messages, list) or not messages: + log(f"Invalid messages in trajectory: {type(messages).__name__}") + print("Error: `messages` must be a non-empty list.", file=sys.stderr) + sys.exit(1) + + log(f"Trajectory has {len(messages)} messages") + + # Atomically claim a unique output path (handles same-second races) + trajectories_dir = get_trajectories_dir() + output_path, fd = open_trajectory_file(trajectories_dir) + + # Write formatted JSON via the already-opened owner-only fd + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(trajectory, f, indent=2, default=str) + f.write("\n") + log(f"Wrote trajectory to {output_path}") + except OSError as e: + log(f"Failed to write trajectory: {e}") + print(f"Error: Failed to write file - {e}", file=sys.stderr) + sys.exit(1) + + print(f"Trajectory saved: {output_path}") + print(f"Messages: {len(messages)}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/save/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md similarity index 99% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/save/SKILL.md rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md index b39e5a39..c7c67712 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/save/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md @@ -1,7 +1,6 @@ --- name: save description: Captures the current session's successful workflow and saves it as a reusable skill with SKILL.md and helper scripts -context: fork --- # Save Session as Skill @@ -217,9 +216,9 @@ def main(): parser = argparse.ArgumentParser(description="{Script description}") parser.add_argument("{arg1}", help="{description}") parser.add_argument("{arg2}", help="{description}", nargs="?") - + args = parser.parse_args() - + # Implementation based on workflow pattern try: # Core logic here diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md similarity index 56% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/SKILL.md rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md index db34f2de..d41abe74 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md @@ -26,22 +26,14 @@ repos: - `scope: read` — download-only. Synced on every run. - `scope: write` — publish target. Synced on every run too, so you see - what you have already published (and anything others have pushed to the - same repo). - -This skill adds one entry to `repos:` and clones it locally. + what you have already published and anything others have pushed. ## Workflow ### Step 1: Bootstrap config if missing -Check whether `evolve.config.yaml` exists in the project root. - -If it does **not** exist, ask the user: - -> "No `evolve.config.yaml` found. What username would you like to use? (e.g. `vatche`)" - -Then create `evolve.config.yaml` with this minimal content: +If `evolve.config.yaml` does not exist, ask the user for a username and +create: ```yaml identity: @@ -59,30 +51,29 @@ grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore ### Step 2: Gather details -Ask the user in this order: - -> "What is the remote URL for the guidelines repo? (e.g. `git@github.com:alice/evolve-guidelines.git`)" -> "What short name would you like for this repo? (e.g. `alice`)" -> "Scope? `read` (download-only subscription) or `write` (you can also publish to it)." -> "Optional note describing this repo (press Enter to skip)." +Ask the user for: -### Step 3: Check for duplicates +- the remote URL for the guidelines repo +- a short local name such as `alice` +- the scope: `read` (default, subscribe-only) or `write` (also a publish target) +- an optional note -Read `evolve.config.yaml` from the project root. If the name already exists -in `repos:`, tell the user: - -> "A repo named '{name}' is already configured. Unsubscribe it first or choose a different name." - -Then stop. - -### Step 4: Run subscribe script +### Step 3: Run subscribe script ```bash sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:subscribe/scripts/subscribe.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:subscribe/scripts/subscribe.py"; python3 "$script" --name "{name}" --remote "{remote}" --branch main --scope "{scope}" --notes "{notes}"' ``` -### Step 5: Confirm +### Step 4: Confirm + +Tell the user the repo was added and they can run `/evolve-lite:sync` +immediately if they want to pull updates now. -Tell the user: +## Notes -> "Added '{name}' (scope={scope}). Run /evolve-lite:sync to pull the latest guidelines." +- The repo is cloned directly into `.evolve/entities/subscribed/{name}/`, + which doubles as the recall mirror +- Subscribed entities will appear in recall with `[from: {name}]` + annotations +- Read-scope repos use a shallow clone; write-scope repos use a full + clone so publish commits can be rebased and pushed cleanly diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py similarity index 64% rename from platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py index 897eaf49..ef6b0cd0 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Add a repo to the unified ``repos`` list and clone it locally. -Shared (multi-reader, multi-writer) repos are described in evolve.config.yaml as: +Shared (multi-reader, multi-writer) repos are described in +``evolve.config.yaml``: repos: - name: memory @@ -27,8 +28,22 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 VALID_SCOPES, is_valid_repo_name, @@ -37,36 +52,23 @@ save_config, set_repos, ) -from audit import append as audit_append # noqa: E402 def main(): parser = argparse.ArgumentParser() - parser.add_argument("--name", required=True, help="Short repo name (e.g. alice, memory)") + parser.add_argument("--name", required=True, help="Short repo name") parser.add_argument("--remote", required=True, help="Git remote URL") - parser.add_argument("--branch", default="main", help="Branch to track (default: main)") - parser.add_argument( - "--scope", - default="read", - choices=VALID_SCOPES, - help="'read' (subscribe only) or 'write' (publish target; also synced).", - ) - parser.add_argument("--notes", default="", help="Free-form note describing this repo") + parser.add_argument("--branch", default="main", help="Branch to track") + parser.add_argument("--scope", default="read", choices=VALID_SCOPES) + parser.add_argument("--notes", default="") args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) - project_root = str(evolve_dir.resolve().parent) - - if not is_valid_repo_name(args.name): - print( - f"Error: invalid subscription name: {args.name!r} (only A-Z, a-z, 0-9, '.', '_', '-' allowed)", - file=sys.stderr, - ) - sys.exit(1) - + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() dest = (evolve_dir / "entities" / "subscribed" / args.name).resolve() - if not dest.is_relative_to(subscribed_base) or dest == subscribed_base: + + if not is_valid_repo_name(args.name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): print(f"Error: invalid subscription name: {args.name!r}", file=sys.stderr) sys.exit(1) @@ -75,23 +77,16 @@ def main(): for repo in repos: if repo.get("name") == args.name: - print( - f"Error: subscription '{args.name}' already exists in config.", - file=sys.stderr, - ) + print(f"Error: subscription '{args.name}' already exists in config.", file=sys.stderr) sys.exit(1) if dest.exists(): - print( - f"Error: directory already exists: {dest}\nRun /evolve-lite:unsubscribe to remove it before re-subscribing.", - file=sys.stderr, - ) + print(f"Error: destination already exists: {dest}", file=sys.stderr) sys.exit(1) dest.parent.mkdir(parents=True, exist_ok=True) # Write-scope repos need full history so the user can safely rebase and - # push publish commits. Read-scope repos only ever mirror, so a shallow - # clone is enough. + # push publish commits. Read-scope repos only mirror, so shallow is enough. clone_cmd = ["git", "clone", args.remote, str(dest), "--branch", args.branch] if args.scope == "read": clone_cmd += ["--depth", "1"] @@ -119,11 +114,11 @@ def main(): set_repos(cfg, repos) try: save_config(cfg, project_root) - except Exception as exc: + except Exception: repos.pop() - shutil.rmtree(dest, ignore_errors=True) - print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) - sys.exit(1) + if dest.exists(): + shutil.rmtree(dest) + raise identity = cfg.get("identity", {}) actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" @@ -137,15 +132,12 @@ def main(): remote=args.remote, ) except Exception as exc: - repos.pop() - set_repos(cfg, repos) - try: - save_config(cfg, project_root) - except Exception: - pass - shutil.rmtree(dest, ignore_errors=True) - print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) - sys.exit(1) + # Audit logging is best-effort: a failed append shouldn't roll back + # an otherwise successful subscribe (the repo is cloned, the config + # has the entry). Warn loudly so the user can fix the audit log + # path without losing the subscription. Originally rolled back on + # main's PR #245 (#244 e2e fix). + print(f"Warning: failed to append audit entry for subscribe: {exc}", file=sys.stderr) print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md new file mode 100644 index 00000000..9030c115 --- /dev/null +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md @@ -0,0 +1,34 @@ +--- +name: sync +description: Pull the latest guidelines from every configured repo (read- and write-scope). +--- + +# Sync Repos + +## Overview + +Pull the latest guidelines from every repo in `evolve.config.yaml` +`repos:` list — both `scope: read` (subscribe-only) and `scope: write` +(publish targets). Write-scope repos use a rebase strategy so any +unpushed local publish commits are preserved. + +## Workflow + +### Step 1: Run sync script + +```bash +sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:sync/scripts/sync.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:sync/scripts/sync.py"; python3 "$script"' +``` + +### Step 2: Display summary + +Show the script output to the user. If there are no repos configured, +tell them they can add one with `/evolve-lite:subscribe`. If there +are no changes, explain that everything is already up to date. + +## Notes + +- Read-scope repos are mirrored exactly via `git fetch` + `git reset --hard` +- Write-scope repos use `git fetch` + `git rebase` so unpushed local + publish commits are preserved +- Sync results are logged to `.evolve/audit.log` diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py similarity index 59% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py index 8038e100..33c34716 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py @@ -4,8 +4,8 @@ Every repo in ``evolve.config.yaml`` (both read- and write-scope) is cloned into ``.evolve/entities/subscribed/{name}/`` so recall sees everything through a single root. Publish commits stay local until pushed, so write-scope repos -use ``git pull --rebase`` (preserves unpushed commits) while read-scope repos -use ``git fetch`` + ``git reset --hard`` (exact mirror). +use ``git fetch`` + ``git rebase`` (preserves unpushed commits) while +read-scope repos use ``git fetch`` + ``git reset --hard`` (exact mirror). Usage: --quiet Suppress output if no changes. @@ -19,20 +19,32 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from config import is_valid_repo_name, load_config, normalize_repos # noqa: E402 +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 +from config import classify_repo_entry, load_config # noqa: E402 _GIT_TIMEOUT = 30 # seconds def _git(repo_path, *args, timeout=_GIT_TIMEOUT): - """Run a git command inside ``repo_path`` with a timeout. Returns CompletedProcess or None.""" try: return subprocess.run( - ["git", "-c", f"safe.directory={repo_path}", "-C", str(repo_path), *args], + ["git", "-C", str(repo_path), *args], capture_output=True, text=True, timeout=timeout, @@ -41,20 +53,8 @@ def _git(repo_path, *args, timeout=_GIT_TIMEOUT): return None -def _head_hash(repo_path): - result = _git(repo_path, "rev-parse", "HEAD") - if result is None or result.returncode != 0: - return None - return result.stdout.strip() - - def sync_read_only(repo_path, branch): - """Fetch and hard-reset to ``origin/{branch}``. Returns CompletedProcess or None on timeout. - - Hard reset ensures the local clone always matches the remote exactly — - restores deleted files and discards any local modifications. Read-only - mirrors have no local commits worth preserving. - """ + """Fetch and hard-reset to origin/{branch} (read-only mirror).""" fetch = _git(repo_path, "fetch", "origin", branch) if fetch is None or fetch.returncode != 0: return fetch @@ -62,37 +62,20 @@ def sync_read_only(repo_path, branch): def sync_writable(repo_path, branch): - """Fetch and rebase local commits onto ``origin/{branch}``. - - Write-scope repos may have local commits from publishing that have not - yet been pushed. Rebase preserves them (no-op when the working tree is - clean) so the user never loses unpushed publish commits. - """ + """Fetch and rebase local commits onto origin/{branch} (preserves publishes).""" fetch = _git(repo_path, "fetch", "origin", branch) if fetch is None or fetch.returncode != 0: return fetch rebase = _git(repo_path, "rebase", f"origin/{branch}") if rebase is None or rebase.returncode != 0: - # Abort a failed rebase so we don't leave the repo in a conflict state. _git(repo_path, "rebase", "--abort") return rebase return rebase def count_delta(repo_path): - """Count added/modified/deleted .md files since last sync. - - Returns dict: ``{added: int, updated: int, removed: int}``. - """ - result = _git( - repo_path, - "diff", - "--name-status", - "HEAD@{1}", - "HEAD", - ) + result = _git(repo_path, "diff", "--name-status", "HEAD@{1}", "HEAD") if result is None or result.returncode != 0: - # HEAD@{1} doesn't exist (initial sync) — count all .md files as added. added = len(list(repo_path.glob("**/*.md"))) return {"added": added, "updated": 0, "removed": 0} added = updated = removed = 0 @@ -117,11 +100,7 @@ def count_delta(repo_path): def main(): parser = argparse.ArgumentParser() parser.add_argument("--quiet", action="store_true", help="Suppress output if no changes") - parser.add_argument( - "--config", - default=None, - help="Path to config file (default: evolve.config.yaml in project root)", - ) + parser.add_argument("--config", default=None, help="Explicit config path") parser.add_argument( "--session-start", action="store_true", @@ -130,7 +109,9 @@ def main(): args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) - project_root = str(evolve_dir.parent) if "EVOLVE_DIR" in os.environ else "." + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir.parent) + audit_root = resolved_evolve_dir if resolved_evolve_dir.name == ".evolve" else resolved_evolve_dir / ".evolve" if args.config: config_path = Path(args.config).resolve() @@ -144,16 +125,30 @@ def main(): else: cfg = load_config(project_root) - # Check sync.on_session_start — only short-circuits automatic hook runs. sync_cfg = cfg.get("sync", {}) if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: sys.exit(0) - repos = normalize_repos(cfg) + raw_entries = cfg.get("repos") if isinstance(cfg, dict) else None + if not isinstance(raw_entries, list): + raw_entries = [] + + repos = [] + rejections = [] + seen = set() + for entry in raw_entries: + repo, rejection = classify_repo_entry(entry) + if rejection is not None: + rejections.append(rejection) + continue + if repo["name"] in seen: + continue + seen.add(repo["name"]) + repos.append(repo) - if not repos: + if not repos and not rejections: if not args.quiet: - print("No subscriptions configured. Add one with /evolve-lite:subscribe to start syncing shared guidelines.") + print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") sys.exit(0) identity = cfg.get("identity", {}) @@ -163,22 +158,28 @@ def main(): total_delta = {} any_changes = False + for rejection in rejections: + raw_name = rejection["raw_name"] + reason = rejection["reason"] + label = repr(raw_name) if raw_name else "" + summaries.append(f"{label} (skipped - {reason})") + for repo in repos: - name = repo.get("name") + name = repo["name"] scope = repo.get("scope", "read") branch = repo.get("branch", "main") remote = repo.get("remote") - if not is_valid_repo_name(name): - summaries.append(f"{name!r} (skipped — invalid subscription name)") - continue + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() - repo_path = evolve_dir / "entities" / "subscribed" / name + if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base): + summaries.append(f"{name!r} (skipped - invalid subscription name)") + continue - head_before = None if not repo_path.is_dir(): if not remote: - summaries.append(f"{name} (not cloned — no remote in config, run /evolve-lite:subscribe first)") + summaries.append(f"{name} (not cloned)") continue repo_path.parent.mkdir(parents=True, exist_ok=True) clone_cmd = ["git", "clone", "--branch", branch] @@ -193,7 +194,7 @@ def main(): timeout=_GIT_TIMEOUT, ) except subprocess.TimeoutExpired: - summaries.append(f"{name} (re-clone failed — timeout)") + summaries.append(f"{name} (re-clone failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue @@ -202,8 +203,6 @@ def main(): total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue - else: - head_before = _head_hash(repo_path) if scope == "write": pull_result = sync_writable(repo_path, branch) @@ -211,46 +210,31 @@ def main(): pull_result = sync_read_only(repo_path, branch) if pull_result is None: - summaries.append(f"{name} (sync failed — timeout)") + summaries.append(f"{name} (sync failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue if pull_result.returncode != 0: - err = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() - short_error = err[-1] if err else f"git exited with {pull_result.returncode}" + error_lines = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() + short_error = error_lines[-1] if error_lines else f"git exited with {pull_result.returncode}" summaries.append(f"{name} (sync failed: {short_error})") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue - head_after = _head_hash(repo_path) - if head_before is not None and head_before == head_after: - delta = {"added": 0, "updated": 0, "removed": 0} - else: - delta = count_delta(repo_path) + delta = count_delta(repo_path) total_delta[name] = delta - - has_changes = any(v > 0 for v in delta.values()) - if has_changes: + if any(value > 0 for value in delta.values()): any_changes = True - delta_str = f"+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed" - summaries.append(f"{name} [{scope}] ({delta_str})") + summaries.append(f"{name} [{scope}] (+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed)") - # Audit - audit_append( - project_root=project_root, - action="sync", - actor=actor, - delta=total_delta, - ) + audit_append(project_root=str(audit_root.parent), action="sync", actor=actor, delta=total_delta) if args.quiet and not any_changes: sys.exit(0) - n = len(summaries) - summary_line = f"Synced {n} repo(s): " + ", ".join(summaries) - print(summary_line) + print(f"Synced {len(summaries)} repo(s): " + ", ".join(summaries)) if __name__ == "__main__": diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md similarity index 54% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/SKILL.md rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md index a8d512e8..808c2f09 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md @@ -8,57 +8,49 @@ description: Remove a repo from the unified repos list and delete its local clon ## Overview Remove a configured repo (any scope) from `evolve.config.yaml` and delete -its local clone. Warn the user before removing a **write-scope** repo since -any locally published entities that haven't been pushed will be lost. +its local clone at `.evolve/entities/subscribed/{name}/`. Warn the user +before removing a write-scope repo since any unpushed local publish +commits will be lost. ## Workflow ### Step 1: List repos -Run the following and display the output as a numbered list. Include each -entry's `scope` and `notes`: +Run: ```bash sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; python3 "$script" --list' ``` -### Step 2: Pick one +Show the repos to the user (including `scope` and `notes`) and ask which +one to remove. -Ask the user: +### Step 2: Confirm -> "Which repo would you like to remove? Enter the number." +Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo has +`scope: write`, add a warning that unpushed local publish commits will be +lost. -### Step 3: Confirm (extra warning if write-scope) - -If the chosen entry has `scope: write`, warn: - -> "'{name}' is a write-scope repo. Removing it will delete the local clone AND any locally published entities that have not yet been pushed. Continue? (y/n)" - -Otherwise: - -> "This will remove '{name}' and delete `.evolve/entities/subscribed/{name}/`. Continue? (y/n)" - -If the user answers anything other than `y` or `yes`, stop and tell them -the operation was cancelled. - -### Step 4: Run unsubscribe script - -For a **read-scope** repo, run: +### Step 3: Run unsubscribe script ```bash sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; python3 "$script" --name {name}' ``` -For a **write-scope** repo (only after the user confirms in Step 3), add -`--force`. The script refuses to remove a write-scope repo without it, -since the local clone may hold unpushed publishes: +For a write-scope repo, the script refuses to remove the local clone +without `--force` so unpushed publishes can't disappear by accident: ```bash sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py"; python3 "$script" --name {name} --force' ``` -### Step 5: Confirm +### Step 4: Confirm + +Tell the user the repo was removed. -Tell the user: +## Notes -> "Removed '{name}'." +- This removes the entry from `evolve.config.yaml` `repos:` list +- Deletes `.evolve/entities/subscribed/{name}/` (the local clone, also + the recall mirror) +- The entities will no longer appear in recall diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py similarity index 70% rename from platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py rename to platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py index 4fc9e697..f0ceeb54 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -"""Remove a repo from the unified ``repos`` list and delete its local clone. - -Works for both read-scope (subscribed) and write-scope (publish target) repos. - -Usage: - --list Print configured repos as a JSON array and exit. - --name {name} Remove the named repo from config and delete its local dir. -""" +"""Remove a repo from the unified ``repos`` list and delete its local clone.""" import argparse import json @@ -15,8 +8,22 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 is_valid_repo_name, load_config, @@ -24,7 +31,6 @@ save_config, set_repos, ) -from audit import append as audit_append # noqa: E402 def main(): @@ -39,8 +45,8 @@ def main(): ) args = parser.parse_args() - project_root = "." evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) cfg = load_config(project_root) repos = normalize_repos(cfg) @@ -50,14 +56,10 @@ def main(): return name = args.name - - if not is_valid_repo_name(name): - print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) - sys.exit(1) - subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() dest = (evolve_dir / "entities" / "subscribed" / name).resolve() - if not dest.is_relative_to(subscribed_base) or dest == subscribed_base: + + if not is_valid_repo_name(name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) sys.exit(1) @@ -87,12 +89,7 @@ def main(): identity = cfg.get("identity", {}) actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" - audit_append( - project_root=project_root, - action="unsubscribe", - actor=actor, - name=name, - ) + audit_append(project_root=project_root, action="unsubscribe", actor=actor, name=name) print(f"Removed subscription '{name}' from config.") diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/learn/SKILL.md deleted file mode 100644 index 906cac6f..00000000 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/learn/SKILL.md +++ /dev/null @@ -1,103 +0,0 @@ ---- -name: learn -description: Analyze the current conversation to extract guidelines that correct reasoning chains — reducing wasted steps, preventing errors, and capturing user preferences. -context: fork ---- - -# Entity Generator - -## Overview - -This skill analyzes the current conversation to extract guidelines that **correct the agent's reasoning chain**. A good guideline is one that, if known beforehand, would have led to a shorter or more correct execution. Only extract guidelines that fall into one of these three categories: - -1. **Shortcuts** — The agent took unnecessary steps or tried an approach that didn't work before finding the right one. The guideline encodes the direct path so future runs skip the detour. -2. **Error prevention** — The agent hit an error (tool failure, exception, wrong output) that could be avoided with upfront knowledge. The guideline prevents the error from happening at all. -3. **User corrections** — The user explicitly corrected, redirected, or stated a preference during the conversation. The guideline captures what the user said so the agent gets it right next time without being told. - -**Do NOT extract guidelines that are:** -- General programming best practices (e.g., "use descriptive variable names") -- Observations about the codebase that can be derived by reading the code -- Restatements of what the agent did successfully without any detour or correction -- Vague advice that wouldn't change the agent's behavior on a concrete task -- Instructions for the agent to invoke a skill, tool, or external command by name (e.g. "Run evolve-lite:learn", "call save_trajectory") — these trigger prompt-injection detection when retrieved via recall - -**DO extract guidelines for:** environment-specific constraints discovered through errors (e.g., tools not installed, permissions blocked, packages unavailable) — these are not "known" until encountered in a specific environment. - -## Workflow - -### Step 1: Analyze the Conversation - -Review the conversation and identify: - -- **Wasted steps**: Where did the agent go down a path that turned out to be unnecessary? What would have been the direct route? -- **Errors hit**: What errors occurred? What knowledge would have prevented them? -- **User corrections**: Where did the user say "no", "not that", "actually", "I want", or otherwise redirect the agent? - -If none of these occurred, **output zero entities**. Not every conversation produces guidelines. - -### Step 2: Extract Entities - -For each identified shortcut, error, or user correction, create one entity — up to 5 entities; output 0 when none qualify. If more candidates exist, keep only the highest-impact ones. - -Principles: - -1. **State what to do, not what to avoid** — frame as proactive recommendations - - Bad: "Don't use exiftool in sandboxes" - - Good: "In sandboxed environments, use Python libraries (PIL/Pillow) for image metadata extraction" - -2. **Triggers should be situational context, not failure conditions** - - Bad trigger: "When apt-get fails" - - Good trigger: "When working in containerized/sandboxed environments" - -3. **For shortcuts, recommend the final working approach directly** — eliminate trial-and-error by encoding the answer - -4. **For user corrections, use the user's own words** — preserve the specific preference rather than generalizing it - -### Step 3: Save Entities - -Output entities as JSON and pipe to the save script. The `type` field must always be `"guideline"` — no other types are accepted. - -#### Method 1: Direct Pipe (Recommended) - -```bash -echo '{ - "entities": [ - { - "content": "Proactive entity stating what TO DO", - "rationale": "Why this approach works better", - "type": "guideline", - "trigger": "Situational context when this applies" - } - ] -}' | sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' -``` - -#### Method 2: From File - -```bash -cat entities.json | sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' -``` - -#### Method 3: Interactive - -```bash -sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:learn/scripts/save_entities.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:learn/scripts/save_entities.py"; python3 "$script"' -# Then paste your JSON and press Ctrl+D -``` - -The script will: -- Find or create the entities directory (`.evolve/entities/`) -- Write each entity as a markdown file in `{type}/` subdirectories -- Deduplicate against existing entities -- Display confirmation with the total count - -## Quality Gate - -Before saving, review each entity against this checklist: - -- [ ] Does it fall into one of the three categories (shortcut, error prevention, user correction)? -- [ ] Would knowing this guideline beforehand have changed the agent's behavior in a concrete way? -- [ ] Is it specific enough that another agent could act on it without further context? -- [ ] Does it avoid instructing the agent to invoke a named skill or tool? - -If any answer is no, drop the entity. **Zero entities is a valid output.** diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/publish/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/publish/SKILL.md deleted file mode 100644 index 00d4d8d9..00000000 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/publish/SKILL.md +++ /dev/null @@ -1,211 +0,0 @@ ---- -name: publish -description: Publish a private guideline to a configured write-scope repo. ---- - -# Publish a Guideline - -## Overview - -Publish one or more private guidelines from `.evolve/entities/guideline/` -into a configured **write-scope** repo. The entity is stamped with -`visibility: public`, `owner`, `published_at`, and `source`, moved into the -local clone of the write repo, and committed / pushed to the remote. - -After publish, the same local clone is also what `/evolve-lite:sync` pulls -from — so you (and anyone else publishing to the same repo) stay in sync. - -## Workflow - -### Step 1: Bootstrap config if missing or incomplete - -Check whether `evolve.config.yaml` exists in the project root. - -**If it does not exist**, or has no write-scope repo configured, first ask: - -> "You need at least one write-scope repo to publish to. Run /evolve-lite:subscribe with --scope write to set one up, then come back." - -Then stop. (Do not silently create a config — the user must explicitly -choose the namespace they publish to.) - -**If it exists** but `identity.user` is missing, ask: - -> "What username would you like to use? (e.g. `alice`)" - -Add it to the config. - -Read `identity.user` from config to use as `{user}` when stamping ownership. - -### Step 2: First-time setup - -Ensure `.evolve/` is gitignored at the project root: - -```bash -grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore -``` - -### Step 3: Pick the target write-scope repo - -Read `repos:` from `evolve.config.yaml`. Filter to entries with -`scope: write`. - -- **Zero entries** → tell the user to subscribe to a write-scope repo first, then stop. -- **Exactly one entry** → use it as the default (no prompt). -- **Multiple entries** → display them as a numbered list with their `notes` - and ask which one to publish to. - -Bind `{repo}` = the chosen entry's `name`, `{remote}` = its `remote`, and -`{branch}` = its `branch` (default `main`). These are referenced in -Steps 5–8 below. - -### Step 4: List and select entities - -List the files in `.evolve/entities/guideline/` (filenames only), display -them numbered, and ask: - -> "Which guideline(s) would you like to publish to '{repo}'? Enter a number or comma-separated list of numbers." - -Wait for the user's selection. - -### Step 5: Ensure the local clone exists - -The target clone lives at `.evolve/entities/subscribed/{repo}/`. If it is -not already a git repo, clone it now: - -```bash -git clone --branch "{branch}" -- "{remote}" ".evolve/entities/subscribed/{repo}" -``` - -(This usually already exists because `/evolve-lite:subscribe` cloned it.) - -### Step 6: Run publish script - -For each selected entity file, run: - -```bash -sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:publish/scripts/publish.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:publish/scripts/publish.py"; python3 "$script" --entity "{filename}" --repo "{repo}" --user "{identity.user}"' -``` - -### Step 7: Commit and push - -Build `{filenames_list}` as a comma-joined list of all selected filenames, -and `{guideline_paths}` as a space-joined list of the corresponding -`guideline/{filename}` paths inside the clone (these are the files the -publish script just wrote). - -```bash -git -C ".evolve/entities/subscribed/{repo}" add -- {guideline_paths} -git -C ".evolve/entities/subscribed/{repo}" commit -m "[evolve] publish: {filenames_list}" -git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" -``` - -If `git push` succeeds, continue to Step 8. - -### Step 7a: Recover from non-fast-forward rejection - -If `git push` failed **and** its stderr contains `rejected`, -`non-fast-forward`, or `fetch first`, another writer pushed to -`{branch}` since your last sync. The local publish commit is intact — -rebase it onto the new remote tip and push once more: - -```bash -git -C ".evolve/entities/subscribed/{repo}" fetch origin "{branch}" -git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" -``` - -- **Rebase clean** → retry the push and continue to Step 8: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" - ``` - -- **Rebase conflicted** → attempt to resolve, then hand off to the - user for review. Do **not** `git rebase --continue` or `git push` - without an explicit user confirmation. - - 1. List the conflicted files: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" status --porcelain - ``` - - Conflict codes: `UU` = both modified, `AA` = both added, - `UD`/`DU` = delete/modify, `A` + binary = binary add. If **any** - file is `UD`, `DU`, or binary, skip straight to the abort branch - below — those are not safe to auto-resolve. - - 2. For each `UU` / `AA` file, read it and produce a resolution: - - The working tree contains `<<<<<<<`, `=======`, `>>>>>>>` - markers. During a rebase, the section above `=======` (labeled - `HEAD`) is the **remote's** version and the section below - (labeled with the publish commit's sha) is **the publish - change being replayed** — i.e., "theirs" and "ours" are - swapped relative to a regular merge. - - Decide an intent-preserving merge: if the edits are - independent (different sections), interleave them. If they - target the same paragraph, prefer keeping both distinct - guideline bodies (e.g. append one under a subheading) rather - than picking a side silently. - - Write the resolved content back to the file. Do **not** - `git add` it yet. - - 3. Show the user what you propose — per file, include a one-line - merge strategy plus the diff against the remote: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" diff HEAD -- {file} - ``` - - Then ask: - - > "I've attempted to resolve {N} conflicted file(s): {list}. - > Each proposed resolution is in - > `.evolve/entities/subscribed/{repo}/`. Review them and say - > **continue** to finish the rebase and push, or **abort** to - > roll back and resolve by hand." - - 4. **User says continue** → stage the resolved files, finish the - rebase, and push: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" add {resolved-files} - git -C ".evolve/entities/subscribed/{repo}" rebase --continue - git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" - ``` - - Then continue to Step 8. If `rebase --continue` surfaces a - **new** conflict (unusual for publish since there's normally one - commit), loop back to step 1 of this block. - - 5. **User says abort**, or the conflict isn't safely resolvable - (binary / delete-modify), or you lack confidence in the merge: - - ```bash - git -C ".evolve/entities/subscribed/{repo}" rebase --abort - ``` - - After the abort the local publish commit is preserved at - `.evolve/entities/subscribed/{repo}` but is not on the remote. - Tell the user: - - > "Rolled back. Your commit for {filenames_list} is preserved - > locally — nothing was lost, but it's not on the remote yet. - > To finish publishing, either: - > - > 1. Resolve by hand: - > - `cd .evolve/entities/subscribed/{repo}` - > - `git fetch origin {branch} && git rebase origin/{branch}` - > - edit the conflicted files, `git add` them, `git rebase --continue` - > - `git push origin {branch}` - > 2. Or, if the conflict is on a shared filename, re-run - > `/evolve-lite:publish` for a different name." - -If `git push` failed for any **other** reason (auth, network, missing -remote ref), surface git's error to the user as-is and stop — a rebase -will not help. - -### Step 8: Confirm - -Tell the user: - -> "Published {filenames_list} to repo '{repo}' ({remote})." diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/SKILL.md deleted file mode 100644 index 73428e68..00000000 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: recall -description: Retrieves relevant entities from a knowledge base. Designed to be invoked automatically via hooks to inject context-appropriate entities before task execution. -context: fork ---- - -# Entity Retrieval - -## Overview - -This skill retrieves relevant entities from a stored knowledge base based on the current task context. It loads all stored entities and presents them to the agent for relevance filtering. - -## How It Works - -1. The PreToolUse hook fires before each tool call -2. Script reads tool input from stdin (best-effort, ignored beyond logging) -3. Loads entities from `.evolve/entities/` — covers private entities, - subscribed read-scope repos, and write-scope publish targets (which - are themselves cloned under `entities/subscribed/{repo}/`) -4. Outputs formatted entities to stdout -5. The agent receives entities as additional context and applies relevant ones - -## Entities Storage - -```text -.evolve/entities/ - guideline/ - use-context-managers-for-file-operations.md ← private - subscribed/ - memory/ ← write-scope clone (publishes land here) - guideline/ - my-published-guideline.md - alice/ ← read-scope clone - guideline/ - alice-guideline.md ← annotated [from: alice] -``` - -Each file uses markdown with YAML frontmatter: - -```markdown ---- -type: guideline -trigger: When processing files or managing resources ---- - -Use context managers for file operations - -## Rationale - -Ensures proper resource cleanup -``` diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py deleted file mode 100644 index 1b400406..00000000 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -"""Retrieve and output entities for the agent to filter. - -In claw-code this script is invoked by the PreToolUse hook -(hooks/retrieve_entities.sh). The hook pipes HOOK_TOOL_INPUT (the about-to-run -tool's JSON input) via stdin, and claw-code also exposes the following env -vars: - - HOOK_EVENT - "PreToolUse" - HOOK_TOOL_NAME - name of the tool about to execute - HOOK_TOOL_INPUT - JSON-encoded tool input (same bytes as stdin) - -The script ignores the tool-specific payload beyond logging it; entity loading -is path-based and independent of which tool is running. -""" - -import json -import os -import sys -from pathlib import Path - -# Add lib to path so we can import entity_io -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import find_recall_entity_dirs, markdown_to_entity, log as _log - - -def log(message): - _log("retrieve", message) - - -log("Script started") - -# Log claw-code hook env vars (and any CLAWD_* vars). -# HOOK_TOOL_INPUT and similar values can contain prompts, tool args, paths, -# code, or secrets, so log only the names of those keys and a redacted -# placeholder. HOOK_EVENT and HOOK_TOOL_NAME are safe to log verbatim. -_LOGGABLE_HOOK_KEYS = {"HOOK_EVENT", "HOOK_TOOL_NAME"} -log("=== Hook Context ===") -hook_keys = [k for k in os.environ if k.startswith(("HOOK_", "CLAWD_"))] -for key in sorted(hook_keys): - if key in _LOGGABLE_HOOK_KEYS: - log(f" {key}={os.environ[key]}") - else: - log(f" {key}=") -if not hook_keys: - log(" (no HOOK_* or CLAWD_* env vars found — may be running outside a hook)") -log("=== End Hook Context ===") - -# Log command-line arguments -log(f" sys.argv: {sys.argv}") - - -def format_entities(entities): - """Format all entities for the agent to review. - - Entities that came from a subscribed source have their path recorded in - the private ``_source`` key (set by load_entities_with_source). These are - annotated with ``[from: {name}]`` so the agent knows their provenance. - """ - header = """## Entities for this task - -Review these entities and apply any relevant ones: - -""" - items = [] - for e in entities: - content = e.get("content") - if not content: - continue - source = e.get("_source") - if source: - content = f"[from: {source}] {content}" - item = f"- **[{e.get('type', 'general')}]** {content}" - if e.get("rationale"): - item += f"\n - _Rationale: {e['rationale']}_" - if e.get("trigger"): - item += f"\n - _When: {e['trigger']}_" - items.append(item) - - return header + "\n".join(items) - - -def load_entities_with_source(entities_dir): - """Glob all .md files under entities_dir and parse each. - - Entities stored under entities/subscribed/{name}/ have ``_source`` set to - the subscription name so format_entities can annotate them. The owner field - written by publish.py is preserved; _source is just a routing key used - internally and is never written to disk. - """ - entities_dir = Path(entities_dir) - entities = [] - for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): - if md.is_symlink(): - continue - try: - entity = markdown_to_entity(md) - if not entity.get("content"): - continue - # Detect subscribed entities by path: .../entities/subscribed/{name}/... - parts = md.parts - try: - entities_index = parts.index("entities") - # Verify the structure is .../entities/subscribed/{name}/... - if entities_index + 2 < len(parts) and parts[entities_index + 1] == "subscribed": - entity["_source"] = parts[entities_index + 2] - except (ValueError, IndexError): - # "entities" not found or invalid structure - not a subscribed entity - pass - entities.append(entity) - except (OSError, UnicodeError): - pass - return entities - - -def main(): - # Read hook context from stdin (retrieve_entities.sh pipes HOOK_TOOL_INPUT - # here). This is best-effort: if stdin is empty or not valid JSON we carry - # on, because entity loading doesn't depend on it. - input_data = {} - try: - raw = sys.stdin.read() - if raw.strip(): - input_data = json.loads(raw) - if isinstance(input_data, dict): - log(f"Parsed stdin — keys: {list(input_data.keys())}") - else: - log(f"Parsed stdin — type: {type(input_data).__name__}") - else: - log("stdin was empty") - except json.JSONDecodeError as e: - log(f"stdin was not valid JSON ({e}), continuing without it") - - recall_dirs = find_recall_entity_dirs() - log(f"Recall dirs: {recall_dirs}") - if not recall_dirs: - log("No entities directory found") - return - - entities = [] - for entities_dir in recall_dirs: - entities.extend(load_entities_with_source(entities_dir)) - - if not entities: - log("No entities found") - return - - log(f"Loaded {len(entities)} entities") - output = format_entities(entities) - print(output) - log(f"Output {len(output)} chars to stdout") - - -if __name__ == "__main__": - main() diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/SKILL.md deleted file mode 100644 index 228639f3..00000000 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/SKILL.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: sync -description: Pull the latest guidelines from every configured repo (read- and write-scope). ---- - -# Sync Repos - -## Overview - -This skill pulls the latest guidelines from every repo in -`evolve.config.yaml` `repos:` list — both `scope: read` (subscribe-only) -and `scope: write` (publish targets). Write-scope repos use a rebase -strategy so any unpushed local publish commits are preserved. - -## Workflow - -### Step 1: Run sync script - -```bash -sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:sync/scripts/sync.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:sync/scripts/sync.py"; python3 "$script"' -``` - -### Step 2: Display summary - -Display the script's stdout verbatim to the user. Example outputs: - -> "Synced 2 repo(s): memory [write] (+2 added, 0 updated, 0 removed), bob [read] (+0 added, 1 updated, 0 removed)" -> -> "No subscriptions configured. Add one with /evolve-lite:subscribe to start syncing shared guidelines." - -Under `--quiet`, the script exits silently when there's nothing to report. diff --git a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json index 861b7272..bf5ab1dd 100644 --- a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json +++ b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json @@ -1,23 +1,29 @@ { "name": "evolve-lite", "version": "1.1.0", - "description": "Recall, save, and share Evolve entities in Codex without MCP.", + "description": "Recall, save, and share reusable Evolve entities.", "author": { - "name": "Vinod Muthusamy", - "url": "https://github.com/AgentToolkit/altk-evolve" + "name": "AgentToolkit" }, "homepage": "https://github.com/AgentToolkit/altk-evolve", "repository": "https://github.com/AgentToolkit/altk-evolve", "license": "MIT", - "keywords": ["evolve", "codex", "entities", "memory"], - "skills": "./skills/", + "keywords": [ + "evolve", + "memory", + "entities" + ], + "skills": "./skills/evolve-lite/", "interface": { "displayName": "Evolve Lite", "shortDescription": "Recall, save, and share reusable Evolve entities.", - "longDescription": "A lightweight Codex plugin that helps you save reusable entities, publish selected guidance, subscribe to shared repos, and recall relevant memory automatically on new prompts.", + "longDescription": "A lightweight plugin that helps you save reusable entities, publish selected guidance, subscribe to shared repos, and recall relevant memory automatically on new prompts.", "developerName": "AgentToolkit", "category": "Productivity", - "capabilities": ["Interactive", "Write"], + "capabilities": [ + "Interactive", + "Write" + ], "websiteURL": "https://github.com/AgentToolkit/altk-evolve", "defaultPrompt": [ "Recall Evolve entities for this task.", diff --git a/platform-integrations/codex/plugins/evolve-lite/lib/__init__.py b/platform-integrations/codex/plugins/evolve-lite/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/platform-integrations/codex/plugins/evolve-lite/lib/audit.py b/platform-integrations/codex/plugins/evolve-lite/lib/audit.py new file mode 100644 index 00000000..fd5c535a --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/lib/audit.py @@ -0,0 +1,33 @@ +"""Append-only audit log writer for .evolve/audit.log.""" + +import datetime +import json +import pathlib + + +def append(project_root=".", **fields): + """Append a JSON audit entry to .evolve/audit.log. + + Args: + project_root: Root directory that contains .evolve/. + **fields: Arbitrary key-value fields to include in the log entry. + """ + path = pathlib.Path(project_root) / ".evolve" / "audit.log" + path.parent.mkdir(parents=True, exist_ok=True) + entry = {**fields, "ts": datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z")} + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +if __name__ == "__main__": + import tempfile + + with tempfile.TemporaryDirectory() as d: + append(project_root=d, action="test", actor="alice") + log_path = __import__("pathlib").Path(d) / ".evolve" / "audit.log" + line = log_path.read_text(encoding="utf-8").strip() + entry = __import__("json").loads(line) + assert entry["action"] == "test" + assert entry["actor"] == "alice" + assert "ts" in entry + print("audit.py ok") diff --git a/platform-integrations/codex/plugins/evolve-lite/lib/config.py b/platform-integrations/codex/plugins/evolve-lite/lib/config.py new file mode 100644 index 00000000..4820494f --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/lib/config.py @@ -0,0 +1,518 @@ +"""Shared config reader/writer for evolve.config.yaml (project root). + +pyyaml is not assumed to be installed. This module implements a minimal +YAML reader/writer that handles the flat and single-level-nested structures +used by evolve-lite config files (scalars and lists of scalar-valued dicts). +""" + +import pathlib +import re +import sys + + +VALID_SCOPES = ("read", "write") +_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") + + +# --------------------------------------------------------------------------- +# Minimal YAML helpers (no pyyaml dependency) +# --------------------------------------------------------------------------- + + +def _strip_comments(line): + """Strip a YAML inline comment, preserving '#' inside single/double quotes.""" + quote = None + escape = False + for i, ch in enumerate(line): + if escape: + escape = False + continue + if quote: + if ch == "\\" and quote == '"': + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "#": + return line[:i].rstrip() + return line.rstrip() + + +def _parse_block(lines, start, parent_indent): + """Parse an indented block starting at `start`. + + Returns (value, next_index) where value is either: + - a list (if block starts with '- ') + - a dict (if block contains 'key: value' pairs at the same indent) + + parent_indent is the indent level of the parent key line. + """ + i = start + # Peek ahead to determine type: list or mapping + # Skip blank lines first + while i < len(lines): + stripped = _strip_comments(lines[i]) + if stripped.strip(): + break + i += 1 + if i >= len(lines): + return {}, i + + first_content = _strip_comments(lines[i]) + block_indent = len(first_content) - len(first_content.lstrip()) + + if block_indent <= parent_indent: + # Nothing actually indented under this key + return {}, i + + if first_content.strip().startswith("- "): + # List + items = [] + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if content.startswith("- "): + item_text = content[2:].strip() + if ":" in item_text: + item_dict = {} + ik, _, iv = item_text.partition(":") + item_dict[ik.strip()] = _cast(iv.strip()) + i += 1 + # Collect more keys at deeper indent for this list item + while i < len(lines): + cont = _strip_comments(lines[i]) + if not cont.strip(): + i += 1 + continue + cont_indent = len(cont) - len(cont.lstrip()) + if cont_indent <= cur_indent: + break + cont_content = cont.strip() + if ":" in cont_content: + ck, _, cv = cont_content.partition(":") + item_dict[ck.strip()] = _cast(cv.strip()) + i += 1 + items.append(item_dict) + else: + items.append(_cast(item_text)) + i += 1 + else: + i += 1 + return items, i + else: + # Nested mapping + mapping = {} + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if ":" in content: + k, _, v = content.partition(":") + k = k.strip() + v = v.strip() + if v: + mapping[k] = _cast(v) + i += 1 + else: + # nested further — recurse + nested, i = _parse_block(lines, i + 1, cur_indent) + mapping[k] = nested + else: + i += 1 + return mapping, i + + +def _parse_yaml(text): + """Parse a minimal YAML subset into a Python dict. + + Supports: + - Top-level ``key: value`` scalar pairs + - Top-level ``key:`` with indented nested mappings or list items + - Comments (#) are stripped + """ + result = {} + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + stripped = _strip_comments(line) + if not stripped.strip(): + i += 1 + continue + indent = len(stripped) - len(stripped.lstrip()) + if indent > 0: + # Skip lines that belong to a block we already consumed + i += 1 + continue + key, sep, value = stripped.partition(":") + key = key.strip() + value = value.strip() + if not key: + i += 1 + continue + if value: + result[key] = _cast(value) + i += 1 + else: + # Block value (list or nested mapping) + block_val, i = _parse_block(lines, i + 1, 0) + result[key] = block_val + return result + + +def _cast(value): + """Cast a YAML scalar string to an appropriate Python type. + + Quoted scalars stay strings — that's the whole point of YAML quoting. + Only unquoted scalars get coerced to bool / null / int / float / list. + """ + # Quoted: return the string verbatim (with single-quote unescaping). + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + stripped = value[1:-1] + if value.startswith("'"): + stripped = stripped.replace("''", "'") + return stripped + + if value in ("true", "True", "yes"): + return True + if value in ("false", "False", "no"): + return False + if value in ("null", "~", ""): + return None + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + # Empty list literal + if value == "[]": + return [] + return value + + +def _dump_yaml(obj, indent=0): + """Serialize a Python dict/list to a minimal YAML string.""" + lines = [] + prefix = " " * indent + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, dict): + lines.append(f"{prefix}{k}:") + lines.extend(_dump_yaml(v, indent + 1).splitlines()) + elif isinstance(v, list): + if not v: + lines.append(f"{prefix}{k}: []") + continue + lines.append(f"{prefix}{k}:") + for item in v: + if isinstance(item, dict): + first = True + for ik, iv in item.items(): + if first: + lines.append(f"{prefix} - {ik}: {_scalar(iv)}") + first = False + else: + lines.append(f"{prefix} {ik}: {_scalar(iv)}") + else: + lines.append(f"{prefix} - {_scalar(item)}") + else: + lines.append(f"{prefix}{k}: {_scalar(v)}") + return "\n".join(lines) + + +def _scalar(v): + """Convert a Python value to a YAML scalar string, quoting when necessary.""" + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + + # For non-string types, convert to string + if not isinstance(v, str): + return str(v) + + # Reserved YAML tokens that must be quoted + reserved_tokens = { + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "null", + "Null", + "NULL", + "~", + "yes", + "Yes", + "YES", + "no", + "No", + "NO", + "on", + "On", + "ON", + "off", + "Off", + "OFF", + } + + # YAML indicator characters that require quoting + yaml_indicators = set("-?:[]{},'&*#!|>'\"%@`") + + # Check if quoting is needed + needs_quoting = ( + v in reserved_tokens # Reserved token + or v == "" # Empty string + or v[0] in " \t" + or v[-1] in " \t" # Leading/trailing whitespace + or "#" in v # Comment character + or any(c in yaml_indicators for c in v) # YAML special characters + or v[0] in yaml_indicators # Starts with indicator + ) + + if needs_quoting: + # Use single quotes and escape embedded single quotes by doubling them + escaped = v.replace("'", "''") + return f"'{escaped}'" + + return v + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def load_config(project_root="."): + """Read evolve.config.yaml from the project root and return a dict. + + Returns {} if the file does not exist. + """ + path = pathlib.Path(project_root) / "evolve.config.yaml" + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8") + return _parse_yaml(text) + + +def save_config(cfg, project_root="."): + """Write *cfg* dict to evolve.config.yaml in the project root.""" + path = pathlib.Path(project_root) / "evolve.config.yaml" + content = _dump_yaml(cfg) + path.write_text(content + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Unified repo model (issue #217) +# --------------------------------------------------------------------------- + + +def _coerce_repo(entry): + """Normalize a single repo dict. Returns None if required fields are missing. + + Rejection is silent — callers that want to surface why a particular entry + was dropped should use ``classify_repo_entry`` to get the rejection reason + and report it however they choose. + """ + if not isinstance(entry, dict): + return None + name = entry.get("name") + remote = entry.get("remote") + if not isinstance(name, str) or not name.strip(): + return None + if not is_valid_repo_name(name.strip()): + print( + f"evolve-lite: {name!r} (skipped - invalid subscription name) — only A-Z, a-z, 0-9, '.', '_', '-' allowed", + file=sys.stderr, + ) + return None + if not isinstance(remote, str) or not remote.strip(): + return None + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name.strip(), + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + } + + +def normalize_repos(cfg): + """Return the unified ``repos`` list from *cfg* with invalid entries dropped. + + Invalid entries (missing ``name`` or ``remote``, duplicate names, unknown + scopes) are silently skipped so callers can trust every returned dict. + """ + if not isinstance(cfg, dict): + return [] + raw = cfg.get("repos") + if not isinstance(raw, list): + return [] + result = [] + seen = set() + for entry in raw: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + result.append(repo) + return result + + +def classify_repo_entry(entry): + """Return ``(repo, rejection)`` for one raw ``repos:`` list entry. + + Exactly one of ``repo`` or ``rejection`` is non-None: + - ``repo`` is the normalized dict (same shape as ``normalize_repos`` + items) when the entry is valid. + - ``rejection`` is a dict ``{"raw_name": str_or_None, "reason": str}`` + describing why the entry was dropped. ``reason`` is one of + "invalid subscription name", "missing remote", "unknown scope", or + "malformed entry". + + Used by sync.py (and similar) to surface skipped entries in user-facing + output without re-implementing validation. + """ + if not isinstance(entry, dict): + return None, {"raw_name": None, "reason": "malformed entry"} + raw_name = entry.get("name") + if not isinstance(raw_name, str) or not raw_name.strip(): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + name = raw_name.strip() + if not is_valid_repo_name(name): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + remote = entry.get("remote") + if not isinstance(remote, str) or not remote.strip(): + return None, {"raw_name": raw_name, "reason": "missing remote"} + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None, {"raw_name": raw_name, "reason": "unknown scope"} + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name, + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + }, None + + +def get_repo(cfg, name): + """Return the repo with the given name, or None.""" + for repo in normalize_repos(cfg): + if repo.get("name") == name: + return repo + return None + + +def write_repos(cfg): + """Return only the write-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "write"] + + +def read_repos(cfg): + """Return only the read-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "read"] + + +def set_repos(cfg, repos): + """Replace the ``repos`` list in-place with sanitized entries.""" + if not isinstance(cfg, dict): + return cfg + sanitized = [] + seen = set() + for entry in repos or []: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + sanitized.append(repo) + cfg["repos"] = sanitized + return cfg + + +def is_valid_repo_name(name): + """Return True if *name* is safe to use as a repo / directory name. + + Rejects leading '-' so names can't be confused with git CLI flags when + interpolated into clone paths. + """ + if not isinstance(name, str): + return False + if name in (".", "..") or name.startswith("-"): + return False + return bool(_SAFE_NAME.match(name)) + + +if __name__ == "__main__": + # Quick self-test + import tempfile + + with tempfile.TemporaryDirectory() as d: + cfg = { + "identity": {"user": "alice"}, + "repos": [ + { + "name": "memory", + "scope": "write", + "remote": "git@github.com:alice/evolve.git", + "branch": "main", + "notes": "public memory for foobar project", + }, + { + "name": "bob", + "scope": "read", + "remote": "git@github.com:bob/evolve.git", + "branch": "main", + "notes": "", + }, + ], + "sync": {"on_session_start": True}, + } + save_config(cfg, d) + loaded = load_config(d) + assert loaded["identity"]["user"] == "alice", loaded + assert loaded["sync"]["on_session_start"] is True, loaded + repos = normalize_repos(loaded) + assert len(repos) == 2, repos + assert repos[0]["scope"] == "write", repos + assert repos[1]["name"] == "bob", repos + print("config.py ok") diff --git a/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py b/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py new file mode 100644 index 00000000..b8e0eefa --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/lib/entity_io.py @@ -0,0 +1,298 @@ +"""Shared entity I/O utilities for the Evolve plugin. + +Handles reading and writing entities as flat markdown files with YAML +frontmatter, organized in type-nested directories. +""" + +import datetime +import getpass +import os +import re +import tempfile +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def _get_log_dir(): + """Get user-scoped log directory with restrictive permissions.""" + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + return log_dir + + +_LOG_FILE = os.path.join(_get_log_dir(), "evolve-plugin.log") + + +def log(component, message): + """Append a timestamped message to the shared log file. + + Args: + component: Short label like "retrieve" or "save". + message: The log line. + """ + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [{component}] {message}\n") + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Directory discovery +# --------------------------------------------------------------------------- + + +def get_evolve_dir(): + """Return the .evolve root directory. + + Uses ``EVOLVE_DIR`` env var if set, otherwise ``.evolve/`` in cwd. + Does not create the directory. + """ + env_dir = os.environ.get("EVOLVE_DIR") + if env_dir: + return Path(env_dir) + return Path(".evolve") + + +def find_entities_dir(): + """Locate the entities directory. + + Uses :func:`get_evolve_dir` to determine the base directory, then + returns the ``entities/`` subdirectory Path if it exists, else ``None``. + """ + c = get_evolve_dir() / "entities" + return c if c.is_dir() else None + + +def find_recall_entity_dirs(): + """Locate all directories that should be searched during recall. + + Returns the existing recall roots. Only ``entities/`` is canonical — + private entities live in ``entities/guideline/`` and shared entities + live in ``entities/subscribed/{repo}/guideline/``. + """ + evolve_dir = get_evolve_dir() + candidates = [evolve_dir / "entities"] + return [path for path in candidates if path.is_dir()] + + +def get_default_entities_dir(): + """Return (and create) the default entities directory. + + Uses ``EVOLVE_DIR`` if set, falls back to ``.evolve/entities/``. + """ + base = get_evolve_dir() / "entities" + base.mkdir(parents=True, exist_ok=True) + return base.resolve() + + +# --------------------------------------------------------------------------- +# Slugify / filename helpers +# --------------------------------------------------------------------------- + + +def slugify(text, max_length=60): + """Convert *text* to a filesystem-safe slug. + + >>> slugify("Use temp files for JSON transfer!") + 'use-temp-files-for-json-transfer' + """ + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = text.strip("-") + # Truncate at max_length, but don't break in the middle of a word + if len(text) > max_length: + text = text[:max_length].rsplit("-", 1)[0] + return text or "entity" + + +def unique_filename(directory, slug): + """Return a Path that doesn't collide with existing files in *directory*. + + Tries ``slug.md``, then ``slug-2.md``, ``slug-3.md``, etc. + """ + directory = Path(directory) + candidate = directory / f"{slug}.md" + if not candidate.exists(): + return candidate + n = 2 + while True: + candidate = directory / f"{slug}-{n}.md" + if not candidate.exists(): + return candidate + n += 1 + + +# --------------------------------------------------------------------------- +# Markdown <-> dict conversion +# --------------------------------------------------------------------------- + +_FRONTMATTER_KEYS = ("type", "trigger", "trajectory", "owner", "source", "visibility", "published_at") + + +def entity_to_markdown(entity): + """Serialize an entity dict to markdown with YAML frontmatter. + + Args: + entity: dict with keys ``content``, and optionally ``type``, + ``trigger``, ``rationale``. + + Returns: + A string suitable for writing to a ``.md`` file. + """ + lines = ["---"] + for key in _FRONTMATTER_KEYS: + val = entity.get(key) + if val: + lines.append(f"{key}: {val}") + lines.append("---") + lines.append("") + + content = entity.get("content", "") + lines.append(content) + + rationale = entity.get("rationale") + if rationale: + lines.append("") + lines.append("## Rationale") + lines.append("") + lines.append(rationale) + + lines.append("") + return "\n".join(lines) + + +def markdown_to_entity(path): + """Parse a markdown entity file back into a dict. + + Handles YAML frontmatter with simple ``key: value`` lines (no nested + structures, no PyYAML dependency). + + Returns: + dict with ``content``, ``type``, ``trigger``, ``rationale`` keys. + """ + path = Path(path) + text = path.read_text(encoding="utf-8") + + entity = {} + + # Split frontmatter + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + frontmatter = parts[1].strip() + body = parts[2] + for line in frontmatter.splitlines(): + line = line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + else: + body = text + else: + body = text + + # Split body into content and rationale + body = body.strip() + m = re.search(r"^## Rationale", body, re.MULTILINE) + if m: + content = body[: m.start()].strip() + rationale = body[m.end() :].strip() + if rationale: + entity["rationale"] = rationale + else: + content = body + + if content: + entity["content"] = content + + return entity + + +# --------------------------------------------------------------------------- +# Bulk load / write +# --------------------------------------------------------------------------- + + +def load_all_entities(entities_dir): + """Glob ``**/*.md`` under *entities_dir* and parse each file. + + Returns: + list of entity dicts. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(entities_dir.glob("**/*.md")): + try: + entity = markdown_to_entity(md) + if entity.get("content"): + entities.append(entity) + except OSError: + pass + return entities + + +def write_entity_file(directory, entity): + """Write a single entity as a markdown file under *directory*. + + The file is placed in a ``{type}/`` subdirectory. Uses atomic + write (write to ``.tmp``, then ``os.rename``). + + Returns: + Path to the written file. + """ + _ALLOWED_TYPES = {"guideline", "preference"} + entity_type = entity.get("type", "guideline") + if not isinstance(entity_type, str) or entity_type not in _ALLOWED_TYPES: + entity_type = "guideline" + entity["type"] = entity_type + type_dir = Path(directory) / entity_type + type_dir.mkdir(parents=True, exist_ok=True) + + slug = slugify(entity.get("content", "entity")) + content = entity_to_markdown(entity) + + # Write to a unique temp file first (avoids predictable .tmp collisions) + fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug) + target = None + try: + os.write(fd, content.encode("utf-8")) + os.close(fd) + fd = None + + # Atomically claim the target using O_EXCL; retry on race + while True: + target = unique_filename(type_dir, slug) + try: + claim_fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(claim_fd) + break + except FileExistsError: + continue + + os.replace(tmp_path, target) + return target + except BaseException: + if fd is not None: + os.close(fd) + if os.path.exists(tmp_path): + os.unlink(tmp_path) + # Clean up the 0-byte placeholder if the replace didn't happen + if target and os.path.exists(str(target)) and os.path.getsize(str(target)) == 0: + os.unlink(str(target)) + raise diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md similarity index 72% rename from platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md index 4efd1ff5..086cf355 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md @@ -7,7 +7,7 @@ description: Must be used near the end of any non-trivial turn that produced pot ## Overview -This skill analyzes the current Codex conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. +This skill analyzes the current conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. ## When To Use @@ -27,7 +27,7 @@ Examples of artifacts that must be immediately created once proven as the succes Unless that artifact happens to be: - code which is a trivial one-liner that future agents would not benefit from reusing - code which embeds secrets, tokens, or user-specific sensitive data -- the guideline would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session +- a guideline that would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run evolve-lite:learn", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session - the user explicitly asked for a one-off result and not to persist helper code - redundant because an equivalent local artifact on disk would be just as effective @@ -81,7 +81,19 @@ If you create an artifact, record: - what it does - when future agents should use it first -### Step 4: Extract Entities +### Step 4: Review Existing Guidelines + +Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. + +Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. + +**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. + +If there are no existing guidelines, skip this step. + +With the existing-guideline set in mind, when you proceed to Step 5 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) + +### Step 5: Extract Entities If Step 3 produced an artifact, at least one entity must explicitly point to that artifact, which is likely the only entity that needs to be produced. Otherwise, extract 3-5 proactive entities. Prioritize entities derived from errors identified in Step 2. @@ -113,9 +125,9 @@ Follow these principles: 5. **Prefer entities that save future time** - A pointer to a saved working script is more valuable than a generic reminder if both are available -### Step 5: Output Entities JSON +### Step 6: Output Entities JSON -Output entities in this JSON format: +Output entities in this JSON format. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. ```json { @@ -124,7 +136,8 @@ Output entities in this JSON format: "content": "Proactive entity stating what TO DO", "rationale": "Why this approach works better", "type": "guideline", - "trigger": "Situational context when this applies" + "trigger": "Situational context when this applies", + "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" } ] } @@ -136,27 +149,26 @@ Allowed type values: - script - command-template -### Step 6: Save Entities +### Step 7: Save Entities After generating the entities JSON, save them using the helper script: - #### Method 1: Direct Pipe (Recommended) ```bash -echo '' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +echo '' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py" ``` #### Method 2: From File ```bash -cat entities.json | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +cat entities.json | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py" ``` #### Method 3: Interactive ```bash -python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py" ``` The script will: diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py new file mode 100644 index 00000000..d26afbcb --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Stop hook that triggers the learn skill to extract guidelines.""" + +import json +import sys +from pathlib import Path + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + if input_data.get("stop_hook_active") is True: + return + + transcript_path = input_data.get("transcript_path", "") + reason = "Run the /evolve-lite:learn skill." + if transcript_path: + session_id = Path(transcript_path).stem.removeprefix("claude-transcript_") + if session_id: + saved_trajectory = f".evolve/trajectories/claude-transcript_{session_id}.jsonl" + reason += f" The saved trajectory path is: {saved_trajectory}" + + print( + json.dumps( + { + "decision": "block", + "reason": reason, + "suppressOutput": True, + "systemMessage": "Running the evolve-lite learn skill...", + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh new file mode 100755 index 00000000..b62b110c --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash +INPUT=$(cat) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') + +# Guard against infinite loop — don't inject if already continuing from a stop hook +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +echo '{ + "decision": "block", + "reason": "Run the /evolve-lite:learn skill to extract guidelines from this conversation.", + "suppressOutput": true, + "systemMessage": "Running the evolve-lite learn skill..." +}' diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/learn/scripts/save_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py similarity index 74% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/learn/scripts/save_entities.py rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py index ef64fd45..bd300f84 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/learn/scripts/save_entities.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/scripts/save_entities.py @@ -10,9 +10,22 @@ import sys from pathlib import Path -# Add lib to path so we can import entity_io -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import ( +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import ( # noqa: E402 find_entities_dir, get_default_entities_dir, load_all_entities, @@ -38,7 +51,6 @@ def main(): parser.add_argument("--user", default=None, help="Stamp owner on every entity written") args = parser.parse_args() - # Read entities from stdin try: input_data = json.load(sys.stdin) log(f"Received input with keys: {list(input_data.keys())}") @@ -59,7 +71,6 @@ def main(): log(f"Received {len(new_entities)} new entities") - # Find or create entities directory entities_dir = find_entities_dir() if entities_dir: entities_dir = entities_dir.resolve() @@ -70,12 +81,10 @@ def main(): log(f"Created new dir: {entities_dir}") print(f"Created new entities dir: {entities_dir}") - # Load existing entities for dedup existing_entities = load_all_entities(entities_dir) existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} log(f"Existing entities: {len(existing_entities)}") - # Write new entities as markdown files added_count = 0 for entity in new_entities: content = entity.get("content") @@ -86,10 +95,11 @@ def main(): log(f"Skipping duplicate: {content[:60]}") continue - if args.user and not entity.get("owner"): - entity["owner"] = args.user - if not entity.get("visibility"): - entity["visibility"] = "private" + # Stamp owner and visibility from the script, never from stdin. + # Untrusted upstream input (a prompt-injected agent) must not be + # able to spoof either field, so unconditionally overwrite. + entity["owner"] = args.user or "unknown" + entity["visibility"] = "private" path = write_entity_file(entities_dir, entity) existing_contents.add(normalize(content)) diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md similarity index 87% rename from platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md index 80c2e0f3..335eacd7 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/publish/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/SKILL.md @@ -12,7 +12,7 @@ into a configured **write-scope** repo. The entity is stamped with `visibility: public`, `owner`, `published_at`, and `source`, moved into the local clone of the write repo, and committed / pushed to the remote. -The same local clone is also what `/evolve-lite:sync` pulls from — so you +The same local clone is also what `evolve-lite:sync` pulls from — so you and anyone else publishing to the same repo stay in sync. ## Workflow @@ -54,10 +54,7 @@ List files in `.evolve/entities/guideline/` and ask the user which to publish. For each selected file, run: ```bash -python3 plugins/evolve-lite/skills/publish/scripts/publish.py \ - --entity "{filename}" \ - --repo "{repo}" \ - --user "{identity.user}" +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py" --entity "{filename}" --repo "{repo}" --user "{identity.user}" ``` ### Step 6: Commit and push @@ -134,3 +131,12 @@ ref), surface git's error and stop — rebase will not help. ### Step 7: Confirm Tell the user what was published and to which repo. + +## Notes + +- Published entities are **moved** from `.evolve/entities/guideline/` into + the write-scope clone at `.evolve/entities/subscribed/{repo}/guideline/`, + with `visibility: public`, `owner: {user}`, `published_at`, and `source` + stamped in frontmatter +- The original private entity is deleted after successful publication +- All publish actions are logged to `.evolve/audit.log` diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/publish/scripts/publish.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py similarity index 63% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/publish/scripts/publish.py rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py index 1c8d2ecb..cf1c128e 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/publish/scripts/publish.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py @@ -1,17 +1,5 @@ #!/usr/bin/env python3 -"""Publish a private guideline entity to a write-scope repo. - -The ``--repo`` flag selects which configured write-scope repo to publish to. -If omitted and exactly one write-scope repo is configured, it is used by -default. The entity is moved from ``.evolve/entities/guideline/{filename}`` -into the target repo's local clone at -``.evolve/entities/subscribed/{repo}/guideline/{filename}``. The skill -orchestration (SKILL.md) is responsible for the subsequent git add / commit -/ push. - -Published entities are stamped with ``visibility=public``, ``owner``, -``published_at``, and ``source``. -""" +"""Publish a private guideline entity to a write-scope repo.""" import argparse import datetime @@ -19,27 +7,38 @@ import re import sys import tempfile -from pathlib import Path - -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from entity_io import markdown_to_entity, entity_to_markdown # noqa: E402 +from pathlib import Path, PurePath + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 +from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402 from config import get_repo, load_config, normalize_repos, write_repos # noqa: E402 def _resolve_source(repo, effective_user): - """Derive the ``source`` frontmatter tag for a published entity.""" remote = repo.get("remote") if isinstance(repo, dict) else None if isinstance(remote, str): - m = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) - if m: - return m.group(1) + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) + if match: + return match.group(1) return effective_user def _select_target_repo(cfg, requested_name): - """Pick the write-scope repo to publish to, or return (None, error_message).""" write = write_repos(cfg) if requested_name: @@ -52,7 +51,7 @@ def _select_target_repo(cfg, requested_name): return repo, None if not write: - return None, ("no write-scope repo configured. Run /evolve-lite:subscribe with --scope write to set up a publish target.") + return None, ("no write-scope repo configured. Run evolve-lite:subscribe with --scope write to set up a publish target.") if len(write) > 1: names = ", ".join(r["name"] for r in write) return None, f"multiple write-scope repos configured; pick one with --repo. Available: {names}" @@ -63,22 +62,20 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--entity", required=True, help="Basename of the .md file to publish") parser.add_argument("--user", default=None, help="Username to stamp as owner") - parser.add_argument( - "--repo", - default=None, - help="Name of the write-scope repo to publish to (optional if exactly one is configured)", - ) + parser.add_argument("--repo", default=None, help="Write-scope repo name (optional if exactly one is configured)") args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir) if evolve_dir.name != ".evolve" else str(resolved_evolve_dir.parent) - # Validate entity name: must be a plain filename with no path components - if len(Path(args.entity).parts) != 1 or args.entity in (".", ".."): + if PurePath(args.entity).name != args.entity or args.entity in {".", ".."}: print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) src_base = (evolve_dir / "entities" / "guideline").resolve() src_path = (evolve_dir / "entities" / "guideline" / args.entity).resolve() + if not src_path.is_relative_to(src_base): print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) @@ -87,16 +84,15 @@ def main(): print(f"Error: entity file not found or is a directory: {src_path}", file=sys.stderr) sys.exit(1) - cfg = load_config(str(evolve_dir.resolve().parent)) - target, err = _select_target_repo(cfg, args.repo) + config = load_config(project_root) + target, err = _select_target_repo(config, args.repo) if err is not None: print(f"Error: {err}", file=sys.stderr) sys.exit(1) - identity = cfg.get("identity", {}) + identity = config.get("identity", {}) effective_user = args.user or (identity.get("user") if isinstance(identity, dict) else None) - # Parse entity and stamp frontmatter entity = markdown_to_entity(src_path) entity["visibility"] = "public" if effective_user: @@ -106,12 +102,11 @@ def main(): if source: entity["source"] = source - # Destination: the local clone of the target write-scope repo. clone_root = evolve_dir / "entities" / "subscribed" / target["name"] if not (clone_root / ".git").exists(): print( f"Error: target repo clone not found at {clone_root}. " - f"Run /evolve-lite:subscribe with --scope write first, or /evolve-lite:sync " + f"Run evolve-lite:subscribe with --scope write first, or evolve-lite:sync " f"to clone it.", file=sys.stderr, ) @@ -123,43 +118,41 @@ def main(): if not dest_path.is_relative_to(dest_base): print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) sys.exit(1) - if dest_path.exists(): print(f"Error: already published: {dest_path}\nUnpublish it first or delete it manually.", file=sys.stderr) sys.exit(1) - content = entity_to_markdown(entity) - tmp_path = None + temp_path = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", - dir=dest_path.parent, + dir=dest_dir, prefix=f".{args.entity}.", suffix=".tmp", delete=False, ) as temp_file: - temp_file.write(content) + temp_file.write(entity_to_markdown(entity)) temp_file.flush() os.fsync(temp_file.fileno()) - tmp_path = Path(temp_file.name) + temp_path = Path(temp_file.name) - tmp_path.replace(dest_path) + temp_path.replace(dest_path) src_path.unlink() finally: - if tmp_path is not None and tmp_path.exists(): - tmp_path.unlink() + if temp_path is not None and temp_path.exists(): + temp_path.unlink() try: audit_append( - project_root=str(evolve_dir.resolve().parent), + project_root=project_root, action="publish", actor=effective_user or "unknown", entity=args.entity, repo=target["name"], ) - except Exception as e: - print(f"Warning: audit log failed: {e}", file=sys.stderr) + except Exception as exc: + print(f"Warning: failed to append audit entry for publish: {exc}", file=sys.stderr) print(f"Published: {args.entity} -> {dest_path} (repo: {target['name']})") diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md similarity index 82% rename from platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md index 2619609e..d0587a93 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md @@ -27,7 +27,7 @@ Before any non-trivial local work, you must complete the recall workflow below. Do not proceed to other analysis or tool use until all steps below are complete. -1. Inspect `.evolve/entities/` for guidance relevant to the current task. +1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. 2. Read each matching entity file that appears relevant. 3. Summarize the applicable guidance in your own words before proceeding. 4. If no relevant entities exist, state that explicitly before proceeding. @@ -36,12 +36,12 @@ Do not proceed to other analysis or tool use until all steps below are complete. Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: -- `Recall complete: searched .evolve/entities/, read , applicable guidance: ` -- `Recall complete: searched .evolve/entities/, no relevant entities found` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, read , applicable guidance: ` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` ### Minimum Acceptable Procedure -1. List or search files under `.evolve/entities/`. +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. 2. Identify candidate entities relevant to the task. 3. Open and read those entity files. 4. Summarize what applies, or state that nothing applies. @@ -51,7 +51,7 @@ Before moving on, produce an explicit completion note in your reasoning or user The skill is not complete if any of the following are true: - You only read this `SKILL.md` -- You did not inspect `.evolve/entities/` +- You did not inspect `${EVOLVE_DIR:-.evolve}/entities/` - You did not read the relevant entity files - You proceeded without stating whether guidance was found @@ -59,12 +59,14 @@ The skill is not complete if any of the following are true: 1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent. 2. The helper script reads the prompt JSON from stdin. -3. It loads stored entities from `.evolve/entities/` (covers private, +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, read-scope subscriptions, and write-scope publish targets which all live under `entities/subscribed/{repo}/`). 4. It prints formatted guidance to stdout. 5. Codex adds that text as extra developer context for the turn. +If hooks are not enabled, complete the **Required Action** workflow above manually. + ## Entities Storage ```text diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py new file mode 100644 index 00000000..ade892fe --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for the agent to use as extra context.""" + +import json +import os +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for the agent to review. + + Entities that came from a subscribed source have their path recorded in + the private ``_source`` key (set by load_entities_with_source). These are + annotated with ``[from: {name}]`` so the agent knows their provenance. + """ + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + source = entity.get("_source") + if source: + content = f"[from: {source}] {content}" + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def load_entities_with_source(entities_dir): + """Load markdown entities from one recall root and annotate subscribed content. + + Symlinks and any files inside a ``.git`` directory are skipped so we don't + surface git's own bookkeeping or sneak past path validation when a write + -scope clone lives under entities/subscribed/{name}/. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): + if md.is_symlink(): + continue + try: + entity = markdown_to_entity(md) + except (OSError, UnicodeError): + continue + if not entity.get("content"): + continue + + entity.pop("_source", None) + parts = md.relative_to(entities_dir).parts + if parts and parts[0] == "subscribed" and len(parts) > 1: + entity["_source"] = parts[1] + + entities.append(entity) + + return entities + + +def main(): + # Hook context arrives via stdin as JSON when invoked from a hook + # (claude/claw-code/codex). Handle empty/absent stdin gracefully so the + # script also works when invoked manually (no hook upstream). + input_data = {} + try: + raw = sys.stdin.read() + if raw.strip(): + input_data = json.loads(raw) + if isinstance(input_data, dict): + log(f"Input keys: {list(input_data.keys())}") + else: + log(f"Input type: {type(input_data).__name__}") + else: + log("stdin was empty") + except json.JSONDecodeError as e: + log(f"stdin was not valid JSON ({e})") + return + + if isinstance(input_data, dict): + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + entities_dir = find_entities_dir() + log(f"Entities dir: {entities_dir}") + + entities = [] + if entities_dir: + entities = load_entities_with_source(entities_dir) + + if not entities: + log("No entities found") + return + + log(f"Loaded {len(entities)} entities") + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md similarity index 76% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md index c3c18604..ad37821b 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/SKILL.md @@ -91,30 +91,47 @@ Convert each message to the appropriate format: Strip `...` tags and their contents from all message content. Use a non-greedy multiline match (e.g., `re.sub(r'[\s\S]*?', '', text).strip()`). If after stripping, a message has empty content and no tool calls, omit it. -### Step 4: Save via Script +### Step 4: Build Envelope -Wrap the messages array in the trajectory envelope and pipe it to the save script using a heredoc. The script handles directory creation, timestamped filename, and file writing. +Wrap the messages array in a trajectory envelope: -```bash -python3 .bob/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py << 'TRAJECTORY_END' +```json { "model": "", "timestamp": "2025-01-15T10:30:00Z", "messages": [...] } -TRAJECTORY_END ``` -- **model**: Use the exact model ID from the current session's environment context (e.g., the value after "You are powered by the model named ..."). Do not hardcode a default — always read it from the session. +- **model**: Use the exact model ID from the current session's environment context (e.g., the value after "You are powered by the model named …"). Do not hardcode a default — always read it from the session. - **timestamp**: Current ISO 8601 timestamp -- Use a single-quoted heredoc delimiter (`<< 'TRAJECTORY_END'`) so the content is passed literally with no shell interpolation — safe for any quotes, backslashes, or newlines in conversation content. -The script will print the saved file path — note it for use in the learn skill. +### Step 5: Save via Helper Script + +Write the trajectory JSON to a temporary file using the **Write** tool, then pass the file path to the helper script: + +1. Write the JSON to `.evolve/tmp/trajectory_input.json` using the Write tool (create the directory if needed) +2. Run the helper script with the file path as an argument: + +```bash + +``` + +**Important**: Do NOT use inline Python scripts, heredocs, or stdin piping to pass the trajectory JSON. Always use the Write tool to create a temp file first. This avoids escaping issues with backslashes, quotes, and newlines in conversation content. + +The script will: +- Read the trajectory JSON from the provided file path +- Create the `.evolve/trajectories/` directory if needed +- Generate a timestamped filename (`trajectory_YYYY-MM-DDTHH-MM-SS.json`) +- Write the formatted JSON +- Print confirmation with file path and message count ## Example Output +After saving, you should see output like: + ```text -Trajectory saved: .evolve/trajectories/trajectory_2025-01-15T10-30-00.json +Trajectory saved: /path/to/project/.evolve/trajectories/trajectory_2025-01-15T10-30-00.json Messages: 12 ``` diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py new file mode 100644 index 00000000..81c3400e --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/on_stop.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Stop hook that copies the session transcript to .evolve/trajectories/.""" + +import datetime +import getpass +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory-stop] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + log(f"Stop hook input keys: {list(input_data.keys())}") + log(f"Stop hook input: {json.dumps(input_data, default=str)[:2000]}") + + transcript_path = input_data.get("transcript_path") + if not transcript_path: + log("No transcript_path in stop hook input") + return + + src = Path(transcript_path) + if not src.is_file(): + log(f"Transcript file not found: {src}") + return + + session_id = src.stem + trajectories_dir = get_trajectories_dir() + dst = trajectories_dir / f"claude-transcript_{session_id}.jsonl" + + shutil.copy2(str(src), str(dst)) + log(f"Copied transcript {src} -> {dst}") + print(f"Trajectory saved: {dst}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py new file mode 100755 index 00000000..f34571eb --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Save Trajectory Script +Reads a trajectory JSON from a file path argument (or stdin as fallback) +and writes it to the .evolve/trajectories/ directory. +""" + +import datetime +import getpass +import json +import os +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + """Get log file path, lazily creating the log directory on first use.""" + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + """Append a timestamped message to the log file. Best-effort; never raises.""" + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + """Get the trajectories output directory, creating it if needed. + + Resolution order: + 1. ``EVOLVE_DIR`` env var (matches the documented contract) + 2. ``CLAUDE_PROJECT_ROOT`` env var (the agent's project root) + 3. ``.evolve/`` in the current working directory + """ + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def open_trajectory_file(trajectories_dir): + """Atomically claim a timestamped trajectory file. + + Returns a ``(Path, fd)`` tuple. Uses ``O_CREAT | O_EXCL`` so two saves + racing within the same second pick distinct filenames instead of one + overwriting the other. + """ + now = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + base_name = f"trajectory_{now}" + + for suffix in range(0, 1000): + name = f"{base_name}.json" if suffix == 0 else f"{base_name}_{suffix}.json" + candidate = trajectories_dir / name + try: + fd = os.open(str(candidate), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + return candidate, fd + except FileExistsError: + continue + + raise RuntimeError(f"Too many trajectory files for timestamp {now}") + + +def main(): + # Read trajectory JSON from file argument or stdin + input_path = sys.argv[1] if len(sys.argv) > 1 else None + try: + if input_path: + log(f"Reading trajectory from file: {input_path}") + with open(input_path, "r", encoding="utf-8") as f: + trajectory = json.load(f) + else: + log("Reading trajectory from stdin") + trajectory = json.load(sys.stdin) + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + except OSError as e: + log(f"Failed to read input: {e}") + print(f"Error: Failed to read input - {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(trajectory, dict): + log(f"Expected JSON object, got {type(trajectory).__name__}") + print(f"Error: Expected JSON object, got {type(trajectory).__name__}", file=sys.stderr) + sys.exit(1) + + log(f"Received trajectory with keys: {list(trajectory.keys())}") + messages = trajectory.get("messages") + if not isinstance(messages, list) or not messages: + log(f"Invalid messages in trajectory: {type(messages).__name__}") + print("Error: `messages` must be a non-empty list.", file=sys.stderr) + sys.exit(1) + + log(f"Trajectory has {len(messages)} messages") + + # Atomically claim a unique output path (handles same-second races) + trajectories_dir = get_trajectories_dir() + output_path, fd = open_trajectory_file(trajectories_dir) + + # Write formatted JSON via the already-opened owner-only fd + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(trajectory, f, indent=2, default=str) + f.write("\n") + log(f"Wrote trajectory to {output_path}") + except OSError as e: + log(f"Failed to write trajectory: {e}") + print(f"Error: Failed to write file - {e}", file=sys.stderr) + sys.exit(1) + + print(f"Trajectory saved: {output_path}") + print(f"Messages: {len(messages)}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md new file mode 100644 index 00000000..3ce7c5cd --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/save/SKILL.md @@ -0,0 +1,470 @@ +--- +name: save +description: Captures the current session's successful workflow and saves it as a reusable skill with SKILL.md and helper scripts +--- + +# Save Session as Skill + +## Overview + +This skill analyzes your current successful session and generates a new reusable skill with: +- **SKILL.md**: Comprehensive documentation with workflow steps, parameters, and examples +- **Helper scripts**: Python scripts for any programmatic operations identified in the workflow + +It extracts the workflow pattern from your conversation history (user requests, reasoning steps, tool calls, and responses) and creates parameterized files that can be invoked in future sessions. + +Use this skill when you've completed a task successfully and want to save the workflow for future reuse. + +## When to Use + +- After completing a multi-step task successfully +- When you've discovered a useful workflow pattern +- When you want to standardize a process for future use +- After solving a problem that might recur +- When the workflow involves programmatic operations that could benefit from helper scripts + +## Workflow + +### Step 1: Review Current Session + +Analyze the conversation history available in the current context, which includes: + +- **User messages**: All requests and questions from the user +- **Assistant reasoning**: Thinking tags and decision-making process +- **Tool calls**: All tools invoked with their arguments +- **Tool responses**: Results and outcomes from each tool +- **Final outcome**: The successful result achieved + +**Action**: Review the entire conversation from start to current point + +### Step 2: Identify the Workflow Pattern + +Extract the high-level workflow by: + +1. **Identifying the goal**: What was the user trying to accomplish? +2. **Grouping related actions**: Which tool calls belong together as logical steps? +3. **Recognizing decision points**: Where did the workflow branch based on conditions? +4. **Noting error handling**: How were errors or edge cases handled? +5. **Extracting the sequence**: What is the step-by-step process? + +**Example Pattern Recognition**: +``` +User Goal: "Read a file and display its contents" + +Workflow Pattern: +1. Attempt to read file at expected location +2. If access denied → check allowed directories +3. Search for file in allowed directories +4. Read file from correct location +5. Format and present results +``` + +### Step 3: Identify Parameterizable Values + +Apply **conservative parameterization** - only parameterize obvious session-specific values: + +**Parameterize**: +- Absolute file paths → `{file_path}` or `{directory}` +- Specific file names → `{filename}` +- User-specific data → `{data_value}` +- Project-specific names → `{project_name}` +- Workspace directories → `{workspace_dir}` + +**Keep Unchanged**: +- Tool names (e.g., `read_file`, `execute_command`) +- General patterns and logic +- Error handling approaches +- Workflow structure + +**Example**: +``` +Original: "Read /home/user/projects/myapp/config.json" +Parameterized: "Read {project_dir}/{config_file}" +``` + +### Step 4: Identify Script Opportunities + +Analyze the workflow to determine if helper scripts would be beneficial: + +**Generate scripts when the workflow includes**: +- Data transformation or parsing (JSON, CSV, XML processing) +- File operations (reading, writing, searching, filtering) +- API calls or HTTP requests +- Complex calculations or data analysis +- Repetitive operations that could be automated +- Integration with external tools or services + +**Script Types to Consider**: +- **Data processors**: Parse, transform, or validate data +- **File handlers**: Read, write, or manipulate files +- **API clients**: Interact with external services +- **Validators**: Check inputs or outputs +- **Formatters**: Convert data between formats + +**Example**: +``` +Workflow includes: Reading JSON file, extracting specific fields, formatting output +→ Generate: parse_and_format.py script +``` + +### Step 5: Generate Skill Document + +Create a new SKILL.md file with the following structure: + +```markdown +--- +name: {skill-name} +description: {one-line description of what this skill does} +--- + +# {Skill Title} + +## Overview + +{Brief description of the skill's purpose and when to use it} + +## Parameters + +{List parameters the user needs to provide} + +- **{param_name}**: {description and example} + +## Workflow + +### Step 1: {Step Name} + +{What this step does} + +**Action**: {Tool or approach to use} + +**Example**: +``` +{Example tool call or command} +``` + +{If helper script exists, reference it} +**Helper Script**: Use `scripts/{script_name}.py` for this operation + +{Repeat for each step} + +## Helper Scripts + +{If scripts were generated, document them} + +### {script_name}.py + +**Purpose**: {What the script does} + +**Usage**: +```bash +python3 plugins/evolve-lite/skills/{skill-name}/scripts/{script_name}.py [arguments] +``` + +**Parameters**: +- `{param}`: {description} + +**Example**: +```bash +python3 plugins/evolve-lite/skills/{skill-name}/scripts/parse_data.py input.json +``` + +## Error Handling + +{Common errors and how to handle them} + +## Examples + +### Example 1: {Use Case} + +**Input**: +- {param}: {value} + +**Expected Output**: +{What the user should see} + +## Notes + +{Additional guidelines or context} +``` + +### Step 6: Generate Helper Scripts + +For each identified script opportunity, create a Python script with: + +**Script Template**: +```python +#!/usr/bin/env python3 +""" +{Script description} + +Usage: + python3 {script_name}.py [arguments] + +Arguments: + {arg1}: {description} + {arg2}: {description} +""" + +import sys +import json +import argparse +from pathlib import Path + + +def main(): + """Main function implementing the script logic.""" + parser = argparse.ArgumentParser(description="{Script description}") + parser.add_argument("{arg1}", help="{description}") + parser.add_argument("{arg2}", help="{description}", nargs="?") + + args = parser.parse_args() + + # Implementation based on workflow pattern + try: + # Core logic here + result = process_data(args.{arg1}) + print(json.dumps(result, indent=2)) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def process_data(input_data): + """Process the input data according to the workflow pattern.""" + # Implementation extracted from session workflow + pass + + +if __name__ == "__main__": + main() +``` + +**Script Guidelines**: +- Include proper error handling +- Accept parameters via command-line arguments +- Output results in a structured format (JSON when appropriate) +- Include usage documentation in docstring +- Make scripts executable (`chmod +x`) + +### Step 7: Prompt for Skill Name + +Ask the user: **"What would you like to name this skill?"** + +**Naming Guidelines**: +- Use lowercase letters +- Separate words with hyphens (kebab-case) +- Be descriptive but concise +- Examples: `read-file-with-permissions`, `deploy-to-staging`, `analyze-logs` + +**Suggest a name** based on the workflow if the user is unsure: +- Extract key actions and objects from the workflow +- Combine into a descriptive name +- Example: "Read file → Check permissions → Search" → `read-file-with-permission-check` + +### Step 8: Check for Existing Skill + +Before saving, check if a skill with this name already exists: + +**Action**: Check if `plugins/evolve-lite/skills/{skill-name}/SKILL.md` exists + +**If exists**: +- Inform the user +- Ask: "A skill with this name already exists. Would you like to:" + - Overwrite the existing skill + - Choose a different name + - Cancel + +### Step 9: Save the Skill + +**Action**: Create the skill directory structure and save all files + +1. Create directory: `plugins/evolve-lite/skills/{skill-name}/` +2. Write SKILL.md to: `plugins/evolve-lite/skills/{skill-name}/SKILL.md` +3. If scripts were generated: + - Create directory: `plugins/evolve-lite/skills/{skill-name}/scripts/` + - Write each script to: `plugins/evolve-lite/skills/{skill-name}/scripts/{script_name}.py` + - Make scripts executable: `chmod +x plugins/evolve-lite/skills/{skill-name}/scripts/*.py` +4. Ensure proper permissions (readable by user) + +**Directory Structure**: +``` +plugins/evolve-lite/skills/{skill-name}/ +├── SKILL.md +└── scripts/ (if applicable) + ├── script1.py + └── script2.py +``` + +**Note**: The skill is saved to the user's home directory (`plugins/evolve-lite/skills/`) making it available across all projects. + +### Step 10: Provide Summary + +Present a clear summary to the user: + +``` +✅ Skill saved successfully! + +**Skill Name**: {skill-name} +**Location**: plugins/evolve-lite/skills/{skill-name}/ + +**Files Created**: +- SKILL.md (workflow documentation) +{if scripts} +- scripts/{script1}.py (helper script for {purpose}) +- scripts/{script2}.py (helper script for {purpose}) +{endif} + +**Summary**: {Brief description of what the skill does} + +**Workflow Captured**: +1. {Step 1 summary} +2. {Step 2 summary} +3. {Step 3 summary} +... + +**Parameters**: +- **{param1}**: {description} +- **{param2}**: {description} + +**Helper Scripts**: +{if scripts} +- **{script1}.py**: {what it does} +- **{script2}.py**: {what it does} +{endif} + +**To use this skill**: Simply reference it by name in future sessions: "{skill-name}" +``` + +## Error Handling + +**Session Too Short**: +- If the session has fewer than 3 meaningful exchanges, inform the user +- Suggest completing more of the task before saving as a skill + +**No Clear Workflow**: +- If the conversation doesn't show a clear workflow pattern, ask the user to clarify +- Request: "Could you describe the key steps you want to capture?" + +**Skill Name Conflicts**: +- If the name already exists, provide options (overwrite, rename, cancel) +- Never silently overwrite without user confirmation + +**Invalid Skill Name**: +- If the name contains invalid characters (spaces, special chars), suggest corrections +- Example: "My Skill!" → "my-skill" + +**Script Generation Errors**: +- If script generation fails, save the SKILL.md anyway +- Inform user they can add scripts manually later +- Provide guidance on what the script should do + +## Examples + +### Example 1: Saving a File Reading Workflow (with script) + +**Session Context**: +``` +User: "Read the states.txt file and parse it into a JSON array" +Assistant: [Reads file, parses lines, converts to JSON, outputs result] +User: "Great! Save this as a skill" +``` + +**Generated Skill Name**: `read-and-parse-file` + +**Parameters Identified**: +- `filename`: The file to read +- `output_format`: Format for output (json, csv, etc.) + +**Workflow Captured**: +1. Read file from workspace +2. Parse file contents line by line +3. Convert to specified format +4. Output formatted result + +**Scripts Generated**: +- `parse_file.py`: Reads a file and converts it to JSON format + +**Files Created**: +``` +plugins/evolve-lite/skills/read-and-parse-file/ +├── SKILL.md +└── scripts/ + └── parse_file.py +``` + +### Example 2: Saving a Deployment Workflow (with multiple scripts) + +**Session Context**: +``` +User: "Deploy the app to staging" +Assistant: [Runs tests, builds app, uploads to server, restarts service] +User: "Perfect! Save this workflow" +``` + +**Generated Skill Name**: `deploy-to-staging` + +**Parameters Identified**: +- `app_name`: Name of the application +- `server_address`: Staging server address + +**Workflow Captured**: +1. Run test suite +2. Build application +3. Upload to staging server +4. Restart service +5. Verify deployment + +**Scripts Generated**: +- `run_tests.py`: Execute test suite and report results +- `deploy.py`: Handle upload and service restart + +**Files Created**: +``` +plugins/evolve-lite/skills/deploy-to-staging/ +├── SKILL.md +└── scripts/ + ├── run_tests.py + └── deploy.py +``` + +### Example 3: Simple Workflow (no scripts needed) + +**Session Context**: +``` +User: "List all Python files in the project" +Assistant: [Uses glob tool to find *.py files, displays results] +User: "Save this" +``` + +**Generated Skill Name**: `list-python-files` + +**Workflow Captured**: +1. Use glob tool with pattern "**/*.py" +2. Format and display results + +**Scripts Generated**: None (simple tool call, no script needed) + +**Files Created**: +``` +plugins/evolve-lite/skills/list-python-files/ +└── SKILL.md +``` + +## Notes + +- **Conservative Parameterization**: Only obvious session-specific values are parameterized. You can manually edit the generated skill later for more customization. +- **Cross-Project Availability**: Skills are saved to `plugins/evolve-lite/skills/` making them available in all your projects. +- **Manual Editing**: After generation, you can manually edit the SKILL.md file and scripts to refine the workflow, add more examples, or adjust parameters. +- **Script Reusability**: Generated scripts can be used standalone or called from other scripts. +- **Skill Composition**: Generated skills can reference other skills, creating powerful workflow chains. +- **Version Control**: Consider adding your `plugins/evolve-lite/skills/` directory to version control to track skill evolution. + +## Guidelines for Better Skills + +1. **Complete the task first**: Make sure your workflow is successful before saving it as a skill +2. **Clear session**: The clearer your session workflow, the better the generated skill and scripts +3. **Descriptive names**: Choose skill names that clearly indicate what they do +4. **Test scripts**: After generation, test the helper scripts to ensure they work correctly +5. **Add context**: After generation, consider adding more examples or notes to the skill +6. **Refine scripts**: Review generated scripts and add error handling or features as needed +7. **Document parameters**: Ensure all script parameters are well-documented in both SKILL.md and script docstrings diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md new file mode 100644 index 00000000..bd1c90c2 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/SKILL.md @@ -0,0 +1,79 @@ +--- +name: subscribe +description: Add a shared guidelines repo (read-scope subscription or write-scope publish target) to the unified repos list. +--- + +# Subscribe to a Shared Repo + +## Overview + +Configured guidelines repos are multi-reader / multi-writer git databases, +described in a single unified list in `evolve.config.yaml`: + +```yaml +repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve.git + branch: main + notes: public memory for foobar project + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org +``` + +- `scope: read` — download-only. Synced on every run. +- `scope: write` — publish target. Synced on every run too, so you see + what you have already published and anything others have pushed. + +## Workflow + +### Step 1: Bootstrap config if missing + +If `evolve.config.yaml` does not exist, ask the user for a username and +create: + +```yaml +identity: + user: {username} +repos: [] +sync: + on_session_start: true +``` + +Also ensure `.evolve/` is gitignored: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 2: Gather details + +Ask the user for: + +- the remote URL for the guidelines repo +- a short local name such as `alice` +- the scope: `read` (default, subscribe-only) or `write` (also a publish target) +- an optional note + +### Step 3: Run subscribe script + +```bash +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py" --name "{name}" --remote "{remote}" --branch main --scope "{scope}" --notes "{notes}" +``` + +### Step 4: Confirm + +Tell the user the repo was added and they can run `evolve-lite:sync` +immediately if they want to pull updates now. + +## Notes + +- The repo is cloned directly into `.evolve/entities/subscribed/{name}/`, + which doubles as the recall mirror +- Subscribed entities will appear in recall with `[from: {name}]` + annotations +- Read-scope repos use a shallow clone; write-scope repos use a full + clone so publish commits can be rebased and pushed cleanly diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py similarity index 64% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py index 897eaf49..ef6b0cd0 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/subscribe/scripts/subscribe.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """Add a repo to the unified ``repos`` list and clone it locally. -Shared (multi-reader, multi-writer) repos are described in evolve.config.yaml as: +Shared (multi-reader, multi-writer) repos are described in +``evolve.config.yaml``: repos: - name: memory @@ -27,8 +28,22 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 VALID_SCOPES, is_valid_repo_name, @@ -37,36 +52,23 @@ save_config, set_repos, ) -from audit import append as audit_append # noqa: E402 def main(): parser = argparse.ArgumentParser() - parser.add_argument("--name", required=True, help="Short repo name (e.g. alice, memory)") + parser.add_argument("--name", required=True, help="Short repo name") parser.add_argument("--remote", required=True, help="Git remote URL") - parser.add_argument("--branch", default="main", help="Branch to track (default: main)") - parser.add_argument( - "--scope", - default="read", - choices=VALID_SCOPES, - help="'read' (subscribe only) or 'write' (publish target; also synced).", - ) - parser.add_argument("--notes", default="", help="Free-form note describing this repo") + parser.add_argument("--branch", default="main", help="Branch to track") + parser.add_argument("--scope", default="read", choices=VALID_SCOPES) + parser.add_argument("--notes", default="") args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) - project_root = str(evolve_dir.resolve().parent) - - if not is_valid_repo_name(args.name): - print( - f"Error: invalid subscription name: {args.name!r} (only A-Z, a-z, 0-9, '.', '_', '-' allowed)", - file=sys.stderr, - ) - sys.exit(1) - + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() dest = (evolve_dir / "entities" / "subscribed" / args.name).resolve() - if not dest.is_relative_to(subscribed_base) or dest == subscribed_base: + + if not is_valid_repo_name(args.name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): print(f"Error: invalid subscription name: {args.name!r}", file=sys.stderr) sys.exit(1) @@ -75,23 +77,16 @@ def main(): for repo in repos: if repo.get("name") == args.name: - print( - f"Error: subscription '{args.name}' already exists in config.", - file=sys.stderr, - ) + print(f"Error: subscription '{args.name}' already exists in config.", file=sys.stderr) sys.exit(1) if dest.exists(): - print( - f"Error: directory already exists: {dest}\nRun /evolve-lite:unsubscribe to remove it before re-subscribing.", - file=sys.stderr, - ) + print(f"Error: destination already exists: {dest}", file=sys.stderr) sys.exit(1) dest.parent.mkdir(parents=True, exist_ok=True) # Write-scope repos need full history so the user can safely rebase and - # push publish commits. Read-scope repos only ever mirror, so a shallow - # clone is enough. + # push publish commits. Read-scope repos only mirror, so shallow is enough. clone_cmd = ["git", "clone", args.remote, str(dest), "--branch", args.branch] if args.scope == "read": clone_cmd += ["--depth", "1"] @@ -119,11 +114,11 @@ def main(): set_repos(cfg, repos) try: save_config(cfg, project_root) - except Exception as exc: + except Exception: repos.pop() - shutil.rmtree(dest, ignore_errors=True) - print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) - sys.exit(1) + if dest.exists(): + shutil.rmtree(dest) + raise identity = cfg.get("identity", {}) actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" @@ -137,15 +132,12 @@ def main(): remote=args.remote, ) except Exception as exc: - repos.pop() - set_repos(cfg, repos) - try: - save_config(cfg, project_root) - except Exception: - pass - shutil.rmtree(dest, ignore_errors=True) - print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) - sys.exit(1) + # Audit logging is best-effort: a failed append shouldn't roll back + # an otherwise successful subscribe (the repo is cloned, the config + # has the entry). Warn loudly so the user can fix the audit log + # path without losing the subscription. Originally rolled back on + # main's PR #245 (#244 e2e fix). + print(f"Warning: failed to append audit entry for subscribe: {exc}", file=sys.stderr) print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md similarity index 73% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md index 1c14d0b2..b79ddda1 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/SKILL.md @@ -12,22 +12,19 @@ Pull the latest guidelines from every repo in `evolve.config.yaml` (publish targets). Write-scope repos use a rebase strategy so any unpushed local publish commits are preserved. -**Note**: Unlike Claude Code, Bob does not auto-sync on session start. -You must invoke this skill manually when you want to update guidelines. - ## Workflow ### Step 1: Run sync script ```bash -python3 scripts/sync.py +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py" ``` ### Step 2: Display summary Show the script output to the user. If there are no repos configured, -tell them they can add one with `evolve-lite:subscribe`. If there are -no changes, explain that everything is already up to date. +tell them they can add one with `evolve-lite:subscribe`. If there +are no changes, explain that everything is already up to date. ## Notes @@ -35,4 +32,3 @@ no changes, explain that everything is already up to date. - Write-scope repos use `git fetch` + `git rebase` so unpushed local publish commits are preserved - Sync results are logged to `.evolve/audit.log` -- Run this periodically to stay up to date with shared guidelines diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py similarity index 59% rename from platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py index 8038e100..33c34716 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py @@ -4,8 +4,8 @@ Every repo in ``evolve.config.yaml`` (both read- and write-scope) is cloned into ``.evolve/entities/subscribed/{name}/`` so recall sees everything through a single root. Publish commits stay local until pushed, so write-scope repos -use ``git pull --rebase`` (preserves unpushed commits) while read-scope repos -use ``git fetch`` + ``git reset --hard`` (exact mirror). +use ``git fetch`` + ``git rebase`` (preserves unpushed commits) while +read-scope repos use ``git fetch`` + ``git reset --hard`` (exact mirror). Usage: --quiet Suppress output if no changes. @@ -19,20 +19,32 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) -from config import is_valid_repo_name, load_config, normalize_repos # noqa: E402 +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) from audit import append as audit_append # noqa: E402 +from config import classify_repo_entry, load_config # noqa: E402 _GIT_TIMEOUT = 30 # seconds def _git(repo_path, *args, timeout=_GIT_TIMEOUT): - """Run a git command inside ``repo_path`` with a timeout. Returns CompletedProcess or None.""" try: return subprocess.run( - ["git", "-c", f"safe.directory={repo_path}", "-C", str(repo_path), *args], + ["git", "-C", str(repo_path), *args], capture_output=True, text=True, timeout=timeout, @@ -41,20 +53,8 @@ def _git(repo_path, *args, timeout=_GIT_TIMEOUT): return None -def _head_hash(repo_path): - result = _git(repo_path, "rev-parse", "HEAD") - if result is None or result.returncode != 0: - return None - return result.stdout.strip() - - def sync_read_only(repo_path, branch): - """Fetch and hard-reset to ``origin/{branch}``. Returns CompletedProcess or None on timeout. - - Hard reset ensures the local clone always matches the remote exactly — - restores deleted files and discards any local modifications. Read-only - mirrors have no local commits worth preserving. - """ + """Fetch and hard-reset to origin/{branch} (read-only mirror).""" fetch = _git(repo_path, "fetch", "origin", branch) if fetch is None or fetch.returncode != 0: return fetch @@ -62,37 +62,20 @@ def sync_read_only(repo_path, branch): def sync_writable(repo_path, branch): - """Fetch and rebase local commits onto ``origin/{branch}``. - - Write-scope repos may have local commits from publishing that have not - yet been pushed. Rebase preserves them (no-op when the working tree is - clean) so the user never loses unpushed publish commits. - """ + """Fetch and rebase local commits onto origin/{branch} (preserves publishes).""" fetch = _git(repo_path, "fetch", "origin", branch) if fetch is None or fetch.returncode != 0: return fetch rebase = _git(repo_path, "rebase", f"origin/{branch}") if rebase is None or rebase.returncode != 0: - # Abort a failed rebase so we don't leave the repo in a conflict state. _git(repo_path, "rebase", "--abort") return rebase return rebase def count_delta(repo_path): - """Count added/modified/deleted .md files since last sync. - - Returns dict: ``{added: int, updated: int, removed: int}``. - """ - result = _git( - repo_path, - "diff", - "--name-status", - "HEAD@{1}", - "HEAD", - ) + result = _git(repo_path, "diff", "--name-status", "HEAD@{1}", "HEAD") if result is None or result.returncode != 0: - # HEAD@{1} doesn't exist (initial sync) — count all .md files as added. added = len(list(repo_path.glob("**/*.md"))) return {"added": added, "updated": 0, "removed": 0} added = updated = removed = 0 @@ -117,11 +100,7 @@ def count_delta(repo_path): def main(): parser = argparse.ArgumentParser() parser.add_argument("--quiet", action="store_true", help="Suppress output if no changes") - parser.add_argument( - "--config", - default=None, - help="Path to config file (default: evolve.config.yaml in project root)", - ) + parser.add_argument("--config", default=None, help="Explicit config path") parser.add_argument( "--session-start", action="store_true", @@ -130,7 +109,9 @@ def main(): args = parser.parse_args() evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) - project_root = str(evolve_dir.parent) if "EVOLVE_DIR" in os.environ else "." + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir.parent) + audit_root = resolved_evolve_dir if resolved_evolve_dir.name == ".evolve" else resolved_evolve_dir / ".evolve" if args.config: config_path = Path(args.config).resolve() @@ -144,16 +125,30 @@ def main(): else: cfg = load_config(project_root) - # Check sync.on_session_start — only short-circuits automatic hook runs. sync_cfg = cfg.get("sync", {}) if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: sys.exit(0) - repos = normalize_repos(cfg) + raw_entries = cfg.get("repos") if isinstance(cfg, dict) else None + if not isinstance(raw_entries, list): + raw_entries = [] + + repos = [] + rejections = [] + seen = set() + for entry in raw_entries: + repo, rejection = classify_repo_entry(entry) + if rejection is not None: + rejections.append(rejection) + continue + if repo["name"] in seen: + continue + seen.add(repo["name"]) + repos.append(repo) - if not repos: + if not repos and not rejections: if not args.quiet: - print("No subscriptions configured. Add one with /evolve-lite:subscribe to start syncing shared guidelines.") + print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") sys.exit(0) identity = cfg.get("identity", {}) @@ -163,22 +158,28 @@ def main(): total_delta = {} any_changes = False + for rejection in rejections: + raw_name = rejection["raw_name"] + reason = rejection["reason"] + label = repr(raw_name) if raw_name else "" + summaries.append(f"{label} (skipped - {reason})") + for repo in repos: - name = repo.get("name") + name = repo["name"] scope = repo.get("scope", "read") branch = repo.get("branch", "main") remote = repo.get("remote") - if not is_valid_repo_name(name): - summaries.append(f"{name!r} (skipped — invalid subscription name)") - continue + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() - repo_path = evolve_dir / "entities" / "subscribed" / name + if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base): + summaries.append(f"{name!r} (skipped - invalid subscription name)") + continue - head_before = None if not repo_path.is_dir(): if not remote: - summaries.append(f"{name} (not cloned — no remote in config, run /evolve-lite:subscribe first)") + summaries.append(f"{name} (not cloned)") continue repo_path.parent.mkdir(parents=True, exist_ok=True) clone_cmd = ["git", "clone", "--branch", branch] @@ -193,7 +194,7 @@ def main(): timeout=_GIT_TIMEOUT, ) except subprocess.TimeoutExpired: - summaries.append(f"{name} (re-clone failed — timeout)") + summaries.append(f"{name} (re-clone failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue @@ -202,8 +203,6 @@ def main(): total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue - else: - head_before = _head_hash(repo_path) if scope == "write": pull_result = sync_writable(repo_path, branch) @@ -211,46 +210,31 @@ def main(): pull_result = sync_read_only(repo_path, branch) if pull_result is None: - summaries.append(f"{name} (sync failed — timeout)") + summaries.append(f"{name} (sync failed - timeout)") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue if pull_result.returncode != 0: - err = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() - short_error = err[-1] if err else f"git exited with {pull_result.returncode}" + error_lines = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() + short_error = error_lines[-1] if error_lines else f"git exited with {pull_result.returncode}" summaries.append(f"{name} (sync failed: {short_error})") total_delta[name] = {"added": 0, "updated": 0, "removed": 0} any_changes = True continue - head_after = _head_hash(repo_path) - if head_before is not None and head_before == head_after: - delta = {"added": 0, "updated": 0, "removed": 0} - else: - delta = count_delta(repo_path) + delta = count_delta(repo_path) total_delta[name] = delta - - has_changes = any(v > 0 for v in delta.values()) - if has_changes: + if any(value > 0 for value in delta.values()): any_changes = True - delta_str = f"+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed" - summaries.append(f"{name} [{scope}] ({delta_str})") + summaries.append(f"{name} [{scope}] (+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed)") - # Audit - audit_append( - project_root=project_root, - action="sync", - actor=actor, - delta=total_delta, - ) + audit_append(project_root=str(audit_root.parent), action="sync", actor=actor, delta=total_delta) if args.quiet and not any_changes: sys.exit(0) - n = len(summaries) - summary_line = f"Synced {n} repo(s): " + ", ".join(summaries) - print(summary_line) + print(f"Synced {len(summaries)} repo(s): " + ", ".join(summaries)) if __name__ == "__main__": diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md similarity index 55% rename from platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md index 759360e5..48ce2eb1 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/SKILL.md +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/SKILL.md @@ -19,7 +19,7 @@ commits will be lost. Run: ```bash -python3 plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py --list +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py" --list ``` Show the repos to the user (including `scope` and `notes`) and ask which @@ -28,14 +28,22 @@ one to remove. ### Step 2: Confirm Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo has -`scope: write`, add a warning that unpushed local publishes will be lost. +`scope: write`, add a warning that unpushed local publish commits will be +lost. ### Step 3: Run unsubscribe script ```bash -python3 plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py --name "{name}" +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py" --name {name} ``` ### Step 4: Confirm Tell the user the repo was removed. + +## Notes + +- This removes the entry from `evolve.config.yaml` `repos:` list +- Deletes `.evolve/entities/subscribed/{name}/` (the local clone, also + the recall mirror) +- The entities will no longer appear in recall diff --git a/platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py similarity index 70% rename from platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py rename to platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py index 4fc9e697..f0ceeb54 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -"""Remove a repo from the unified ``repos`` list and delete its local clone. - -Works for both read-scope (subscribed) and write-scope (publish target) repos. - -Usage: - --list Print configured repos as a JSON array and exit. - --name {name} Remove the named repo from config and delete its local dir. -""" +"""Remove a repo from the unified ``repos`` list and delete its local clone.""" import argparse import json @@ -15,8 +8,22 @@ import sys from pathlib import Path -# Add lib to path -sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 from config import ( # noqa: E402 is_valid_repo_name, load_config, @@ -24,7 +31,6 @@ save_config, set_repos, ) -from audit import append as audit_append # noqa: E402 def main(): @@ -39,8 +45,8 @@ def main(): ) args = parser.parse_args() - project_root = "." evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) cfg = load_config(project_root) repos = normalize_repos(cfg) @@ -50,14 +56,10 @@ def main(): return name = args.name - - if not is_valid_repo_name(name): - print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) - sys.exit(1) - subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() dest = (evolve_dir / "entities" / "subscribed" / name).resolve() - if not dest.is_relative_to(subscribed_base) or dest == subscribed_base: + + if not is_valid_repo_name(name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) sys.exit(1) @@ -87,12 +89,7 @@ def main(): identity = cfg.get("identity", {}) actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" - audit_append( - project_root=project_root, - action="unsubscribe", - actor=actor, - name=name, - ) + audit_append(project_root=project_root, action="unsubscribe", actor=actor, name=name) print(f"Removed subscription '{name}' from config.") diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index cd29f389..4527473b 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -481,6 +481,30 @@ class BobInstaller: def __init__(self, ops: FileOps): self.ops = ops + def _purge_evolve_artifacts(self, bob_target): + """Remove every evolve-prefixed skill, command, and directory under bob_target. + + Catches both the current `evolve-lite-` dash form and the legacy + `evolve-lite:` colon form (plus any future `evolve-*` namespace), + so re-running install over an older layout converges to a clean state + instead of accumulating duplicates. User-owned non-evolve content + (`my-custom-skill/`, `my-command.md`, …) is preserved. + """ + skills_dir = bob_target / "skills" + if skills_dir.is_dir(): + for entry in sorted(skills_dir.iterdir()): + if entry.is_dir() and entry.name.startswith("evolve"): + self.ops.remove_dir(entry) + commands_dir = bob_target / "commands" + if commands_dir.is_dir(): + for entry in sorted(commands_dir.iterdir()): + if entry.is_file() and entry.name.startswith("evolve"): + self.ops.remove_file(entry) + if bob_target.is_dir(): + for entry in sorted(bob_target.iterdir()): + if entry.is_dir() and entry.name.startswith("evolve"): + self.ops.remove_dir(entry) + def install(self, target_dir, mode="lite"): _ensure_source_dir() source_dir = SOURCE_DIR @@ -489,10 +513,17 @@ class BobInstaller: info(f"Installing Bob ({mode} mode) → {bob_target}") + # Wipe any existing evolve-prefixed artifacts (legacy colon-form + # skills/commands from before the rename, stale evolve-lib dirs, + # etc.) before re-rendering. Without this, re-running install + # over an old layout would leave duplicate `evolve-lite:` + # alongside the new `evolve-lite-`. + self._purge_evolve_artifacts(bob_target) + if mode == "lite": - shared_lib = Path(source_dir) / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib" + shared_lib = bob_source_lite / "lib" if not self.ops.is_dry_run and not shared_lib.is_dir(): - raise RuntimeError(f"Shared lib not found: {shared_lib} — is the Claude plugin present in the source tree?") + raise RuntimeError(f"Shared lib not found: {shared_lib}") self.ops.copy_tree(shared_lib, bob_target / "evolve-lib") success("Copied Bob lib") @@ -542,15 +573,7 @@ class BobInstaller: bob_target = Path(target_dir) / ".bob" info(f"Uninstalling Bob from {bob_target}") - self.ops.remove_dir(bob_target / "evolve-lib") - skills_dir = bob_target / "skills" - if skills_dir.is_dir(): - for skill_dir in sorted(skills_dir.glob("evolve-lite:*")): - self.ops.remove_dir(skill_dir) - commands_dir = bob_target / "commands" - if commands_dir.is_dir(): - for cmd_file in sorted(commands_dir.glob("evolve-lite:*.md")): - self.ops.remove_file(cmd_file) + self._purge_evolve_artifacts(bob_target) self.ops.remove_yaml_custom_mode(bob_target / "custom_modes.yaml", BOB_SLUG) self.ops.remove_yaml_custom_mode(bob_target / "custom_modes.yaml", "Evolve") self.ops.remove_json_key(bob_target / "mcp.json", ["mcpServers", "evolve"]) @@ -562,14 +585,18 @@ class BobInstaller: print(f" Bob (.bob/):") print(f" evolve-lib/entity_io : {'✓' if (bob_target / 'evolve-lib' / 'entity_io.py').is_file() else '✗'}") skills_dir = bob_target / "skills" - installed_skills = sorted(skills_dir.glob("evolve-lite:*")) if skills_dir.is_dir() else [] + # Glob `evolve*` rather than `evolve-lite-*` so legacy colon-form + # skills (`evolve-lite:learn` etc.) show up in status; otherwise + # an upgrade-gap state would silently report ✗ while artifacts + # still squat on disk. + installed_skills = sorted(p for p in skills_dir.glob("evolve*") if p.is_dir()) if skills_dir.is_dir() else [] if installed_skills: for s in installed_skills: print(f" skills/{s.name} : ✓") else: - print(f" skills/evolve-lite:* : ✗") + print(f" skills/evolve* : ✗") commands_dir = bob_target / "commands" - installed_cmds = sorted(commands_dir.glob("evolve-lite:*.md")) if commands_dir.is_dir() else [] + installed_cmds = sorted(commands_dir.glob("evolve*.md")) if commands_dir.is_dir() else [] print(f" commands/ ({len(installed_cmds)} evolve commands) : {'✓' if installed_cmds else '✗'}") print(f" custom_modes.yaml : {'✓' if (bob_target / 'custom_modes.yaml').is_file() else '✗'}") has_mcp = "evolve" in read_json(bob_target / "mcp.json").get("mcpServers", {}) if (bob_target / "mcp.json").is_file() else False @@ -728,7 +755,7 @@ class CodexInstaller: "sh -lc '" 'd=\"$PWD\"; ' "while :; do " - 'candidate=\"$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py\"; ' + 'candidate=\"$d/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py\"; ' 'if [ -f \"$candidate\" ]; then EVOLVE_DIR=\"$d/.evolve\" exec python3 \"$candidate\"; fi; ' '[ \"$d\" = \"/\" ] && break; ' 'd=\"$(dirname \"$d\")\"; ' @@ -738,7 +765,7 @@ class CodexInstaller: @staticmethod def _is_recall_command(command): - return isinstance(command, str) and "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in command + return isinstance(command, str) and "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" in command @staticmethod def _recall_hook(): @@ -758,7 +785,7 @@ class CodexInstaller: "sh -lc '" 'd=\"$PWD\"; ' "while :; do " - 'candidate=\"$d/plugins/evolve-lite/skills/sync/scripts/sync.py\"; ' + 'candidate=\"$d/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py\"; ' 'if [ -f \"$candidate\" ]; then EVOLVE_DIR=\"$d/.evolve\" exec python3 \"$candidate\" --quiet --session-start; fi; ' '[ \"$d\" = \"/\" ] && break; ' 'd=\"$(dirname \"$d\")\"; ' @@ -768,7 +795,7 @@ class CodexInstaller: @staticmethod def _is_sync_command(command): - return isinstance(command, str) and "plugins/evolve-lite/skills/sync/scripts/sync.py" in command + return isinstance(command, str) and "plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py" in command @staticmethod def _sync_hook(): @@ -1001,12 +1028,6 @@ class CodexInstaller: self.ops.copy_tree(plugin_source, plugin_target) success("Copied Codex plugin") - shared_lib = Path(source_dir) / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib" - if not self.ops.is_dry_run and not shared_lib.is_dir(): - raise RuntimeError(f"Shared lib not found: {shared_lib} — is the Claude plugin present in the source tree?") - self.ops.copy_tree(shared_lib, plugin_target / "lib") - success("Copied Codex lib") - marketplace_target = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" self._upsert_marketplace_entry( marketplace_target, @@ -1049,8 +1070,8 @@ class CodexInstaller: print(" Codex:") print(f" plugins/evolve-lite : {'✓' if plugin_dir.is_dir() else '✗'}") print(f" lib/entity_io.py : {'✓' if (plugin_dir / 'lib' / 'entity_io.py').is_file() else '✗'}") - print(f" skills/learn : {'✓' if (plugin_dir / 'skills' / 'learn').is_dir() else '✗'}") - print(f" skills/recall : {'✓' if (plugin_dir / 'skills' / 'recall').is_dir() else '✗'}") + print(f" skills/evolve-lite/learn : {'✓' if (plugin_dir / 'skills' / 'evolve-lite' / 'learn').is_dir() else '✗'}") + print(f" skills/evolve-lite/recall : {'✓' if (plugin_dir / 'skills' / 'evolve-lite' / 'recall').is_dir() else '✗'}") marketplace_path = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" marketplace_present = ( diff --git a/plugin-source/README.md b/plugin-source/README.md new file mode 100644 index 00000000..5e872279 --- /dev/null +++ b/plugin-source/README.md @@ -0,0 +1,29 @@ +# plugin-source/ + +Canonical source-of-truth for the per-platform plugin code under `platform-integrations/`. + +Edit files **here**, not under `platform-integrations/`. The latter is generated +output, kept under version control for PR review and agent comprehension, and +enforced byte-for-byte against this directory by `just check-plugins-rendered`. + +## Layout + +- `MANIFEST.toml` — declares the configured platforms (each with a target + `plugin_root` under `platform-integrations/`) and the list of files to render. +- `lib/` — Python helpers shared by skill scripts. Copied verbatim to each + platform's plugin tree. +- *(future)* `skills//` — canonical skill content (templates rendered + per-platform). +- *(future)* `platforms//` — per-platform overlay files for content that + exists on only one platform or whose prose is too divergent to template. + +## Workflow + +Edit a source file → run `just compile-plugins` → commit both the source change +and the regenerated output. The pre-commit hook re-renders automatically. + +To verify that the committed output is up-to-date with the source: + + just check-plugins-rendered + +CI runs the same check. diff --git a/plugin-source/_bob/README.md b/plugin-source/_bob/README.md new file mode 100644 index 00000000..b2266e5e --- /dev/null +++ b/plugin-source/_bob/README.md @@ -0,0 +1,225 @@ +# Evolve Lite for Bob + +A Bob integration that helps you learn from conversations by automatically extracting and applying guidelines. + +⭐ Star the repo: https://github.com/AgentToolkit/altk-evolve + +## Features + +- **Manual Learning**: Use `evolve-lite:learn` to extract and save guidelines from conversations +- **Manual Retrieval**: Use `evolve-lite:recall` to retrieve and apply stored guidelines +- **Guideline Sharing**: Subscribe to read-scope repos and publish to write-scope repos via Git + +## Installation + +Run the installation script from the repository root: + +```bash +bash platform-integrations/install.sh install bob lite +``` + +This installs: +- 6 skills in `~/.bob/skills/` +- Shared library in `~/.bob/evolve-lib/` +- Custom mode configuration + +## How It Works + +### Guideline Storage + +Guidelines are stored as individual markdown files in `.evolve/entities/`, +organized by source. Both read-scope subscriptions and write-scope publish +targets live under `entities/subscribed/{name}/`: + +```text +.evolve/entities/ + guideline/ # Private guidelines + use-context-managers.md + subscribed/ + memory/ # write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ # read-scope clone + guideline/ + her-guideline.md +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +visibility: private +--- + +Use context managers for file operations + +## Rationale + +Context managers ensure proper resource cleanup +``` + +## Sharing Guidelines + +Evolve Lite treats shared guidelines as multi-reader / multi-writer git +databases. A single unified `repos:` list in `evolve.config.yaml` +describes every external guideline repo; each entry has a `scope` of +`read` (subscribe only) or `write` (publish target that is also pulled +on sync). + +### Setup + +Sharing requires `evolve.config.yaml` at the project root. If it doesn't +exist, the subscribe or publish skills will prompt you to create one. +Minimal structure: + +```yaml +identity: + user: yourname # used to stamp ownership on published guidelines + +repos: + - name: memory + scope: write + remote: git@github.com:yourname/evolve-memory.git + branch: main + notes: public memory for my open-source projects + - name: team + scope: read + remote: git@github.com:myorg/evolve-guidelines.git + branch: main + +sync: + on_session_start: false +``` + +The `.evolve/` directory is kept out of version control — the skills +automatically add it to `.gitignore`. + +### Subscribing to a Repo + +Use `evolve-lite:subscribe` to add either a read-scope subscription or a +write-scope publish target: + +```text +evolve-lite:subscribe +> Remote URL: git@github.com:alice/evolve-guidelines.git +> Short name: alice +> Scope: read +``` + +The repo is cloned directly into `.evolve/entities/subscribed/{name}/` +(this directory serves as both the git clone and the recall mirror). + +### Publishing Guidelines + +Use `evolve-lite:publish` to share local guidelines via a **write-scope** repo: + +1. The skill picks (or asks about) the write-scope target repo +2. Lists files in `.evolve/entities/guideline/` +3. You pick which ones to publish +4. Each selected file is moved into the write-scope clone at + `.evolve/entities/subscribed/{repo}/guideline/`, stamped with your + username, committed, and pushed to the remote + +Because the publish target is also a subscribed repo, your next sync +pulls in anything other writers have pushed to the same remote. + +### Syncing Repos + +Use `evolve-lite:sync` to pull the latest changes from every configured +repo: + +```text +evolve-lite:sync +> Synced 2 repo(s): memory [write] (+0 added, 1 updated, 0 removed), alice [read] (+2 added, 0 updated, 0 removed) +``` + +Read-scope repos use `git fetch` + `git reset --hard`. Write-scope repos +use `git fetch` + `git rebase` so any unpushed local publish commits are +preserved. + +### Unsubscribing + +Use `evolve-lite:unsubscribe` to remove a configured repo and delete +its locally cloned files: + +```text +evolve-lite:unsubscribe +> Which repo would you like to remove? +> 1. memory [write] +> 2. alice [read] +``` + +The skill confirms before deleting `.evolve/entities/subscribed/{name}/`. +Removing a write-scope repo will also discard any unpushed local +publish commits, so the skill warns first. + +### Sharing Storage Layout + +```text +.evolve/ + entities/ + guideline/ # your private guidelines + my-guideline.md + subscribed/ + memory/ # write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ # read-scope clone (also serves as recall mirror) + guideline/ + her-guideline.md +``` + +## Skills Included + +### `evolve-lite:learn` + +Manually invoke to extract guidelines from the current conversation: +- Analyzes task, steps taken, successes and failures +- Generates proactive guidelines (what to do, not what to avoid) +- Saves guidelines as markdown files in `.evolve/entities/guideline/` + +### `evolve-lite:recall` + +Manually invoke to retrieve and display stored guidelines: +- Loads guidelines from private and subscribed sources +- Formats and displays them for your review +- Annotates subscribed guidelines with their source + +### `evolve-lite:publish` + +Publish private guidelines to a write-scope repo: +- Lists available private guidelines +- Moves selected guidelines into the write-scope clone at + `.evolve/entities/subscribed/{repo}/guideline/` +- Stamps with `owner`, `published_at`, and `source` metadata +- Commits and pushes to the configured remote + +### `evolve-lite:subscribe` + +Add a configured repo to the unified `repos:` list: +- Clones the remote into `.evolve/entities/subscribed/{name}/` +- Adds an entry with `scope: read` or `scope: write` to config + +### `evolve-lite:sync` + +Sync every configured repo: +- Read-scope: fetch + reset --hard (clobbers any local edits) +- Write-scope: fetch + rebase (preserves unpushed local publishes) +- Reports changes (added, updated, removed) + +### `evolve-lite:unsubscribe` + +Remove a configured repo: +- Lists current repos with their scope and notes +- Deletes the local clone at `.evolve/entities/subscribed/{name}/` +- Removes the entry from config + +## Environment Variables + +- `EVOLVE_DIR`: Override the default `.evolve` directory location (guidelines, config, etc. are stored here) + +## Verification + +After installation, the skills should be available in Bob's skill list. diff --git a/plugin-source/_bob/custom_modes.yaml b/plugin-source/_bob/custom_modes.yaml new file mode 100644 index 00000000..aecef224 --- /dev/null +++ b/plugin-source/_bob/custom_modes.yaml @@ -0,0 +1,71 @@ +customModes: + - slug: evolve-lite + name: Evolve Lite + roleDefinition: |- + You are Evolve Lite — a learning mode that improves from every interaction. + + ⚠️ CRITICAL: THE FOLLOWING WORKFLOW IS ABSOLUTELY MANDATORY AND CANNOT BE SKIPPED ⚠️ + + WORKFLOW (4 steps, strict order): + + 1. ⚠️ FIRST ACTION (MANDATORY): Use the evolve-lite:recall skill to retrieve relevant guidelines. + - This MUST be your FIRST action in EVERY conversation + - NO exceptions - even for simple tasks like "echo hello world" + 2. WORK: Complete the user's request. When a retrieved guideline applies, cite it in your reasoning. + 3. ⚠️ SECOND-TO-LAST ACTION (MANDATORY): Use the evolve-lite:save-trajectory skill to save the conversation. + - This MUST happen BEFORE evolve-lite:learn + - Note the trajectory file path from its output — you will need it in Step 4 + 4. ⚠️ LAST ACTION (MANDATORY): Use the evolve-lite:learn skill to extract and save learnings. + - This MUST happen BEFORE attempt_completion + - NO exceptions - even if you think there's nothing to learn + + Before using any skill for the first time in a conversation, read its + SKILL.md so you know the exact invocation syntax. Bob installs skills + under `.bob/skills/`; the runtime will resolve a skill by name. + + ⚠️ ENFORCEMENT RULES ⚠️ + - Do not skip steps. Do not reorder steps. + - Step 1 must happen before any other tool use (except a one-time + SKILL.md read for a skill you have not invoked yet). + - Step 3 must happen before Step 4. + - Step 4 must happen before attempt_completion. + - If you forget Step 1, STOP and call evolve-lite:recall immediately. + - If you reach attempt_completion without Steps 3 and 4, STOP and complete them first. + customInstructions: |- + - ALWAYS read a skill's SKILL.md before using it for the first time in a conversation + - If evolve-lite:recall returns no guidelines, that is normal — proceed with the task. + - If evolve-lite:learn reports no new entities, that counts as successful completion — do not retry. + - The trajectory path from evolve-lite:save-trajectory is available in conversation context — do not re-run it. + - If no errors or non-obvious discoveries occurred, saving zero entities is correct — do not force low-quality entities. + + MEMORY SHARING (Optional): + Additional evolve-lite skills handle sharing guidelines with others + (publish, subscribe, unsubscribe, sync). They are OPTIONAL and do not + affect the core workflow. Use them when you want to share your + learnings with teammates, learn from others' guidelines, or keep + subscribed guidelines up to date. Read the SKILL.md for any of these + before first use. + + PRE-COMPLETION GATE: + Before calling attempt_completion, ask yourself: + ┌─────────────────────────────────────────────────────────────────┐ + │ Did I run the evolve-lite:recall skill at the start? │ + │ Did I run the evolve-lite:save-trajectory skill and note the path?│ + │ Did I run the evolve-lite:learn skill and see its output? │ + │ │ + │ ❌ NO → STOP. Go back and complete the step. │ + │ ✅ YES → Proceed with attempt_completion. │ + └─────────────────────────────────────────────────────────────────┘ + + Rules: + + - Before using a skill for the first time, read its SKILL.md file to understand the correct usage syntax. + - ALWAYS call the evolve-lite:learn skill before attempt_completion, even if the task seems simple or you think there's nothing to learn. + - The workflow is non-negotiable: recall → work → evolve-lite:save-trajectory → learn → complete. + - Skipping evolve-lite:learn violates the core purpose of this mode. + + groups: + - read + - edit + - command + - browser diff --git a/plugin-source/_claude/README.md b/plugin-source/_claude/README.md new file mode 100644 index 00000000..7a62383d --- /dev/null +++ b/plugin-source/_claude/README.md @@ -0,0 +1,282 @@ +# Evolve Lite Plugin for Claude Code + +A plugin that helps Claude Code learn from conversations by automatically extracting and applying entities. + +⭐ Star the repo: https://github.com/AgentToolkit/altk-evolve + +## Features + +- **Automatic Retrieval**: At the start of each prompt, relevant entities are automatically injected +- **Manual Learning**: Use the `/evolve-lite:learn` skill to extract and save entities from conversations +- **Automatic Learning**: After each task, entities are automatically extracted and saved via a Stop hook +- **Zero-config Retrieval**: Hooks are automatically installed when the plugin is enabled + +## Installation + +### From Local Directory + +```bash +claude --plugin-dir /path/to/altk-evolve/platform-integrations/claude/plugins/evolve-lite +``` + +### From Marketplace + +1. Add the marketplace and plugin: + ```bash + claude plugin marketplace add AgentToolkit/altk-evolve + claude plugin install evolve@evolve-marketplace + ``` + + +## How It Works + +### Entity Retrieval (Automatic) + +When you submit a prompt, the plugin automatically: +1. Loads all stored entities from `.evolve/entities/` (one markdown file per entity) +2. Formats and injects them into the conversation context +3. Claude applies relevant entities to the current task + +### Entity Generation (Automatic) + +After Claude completes each task, the plugin automatically invokes the `/evolve-lite:learn` skill via a `Stop` hook: +1. Claude finishes responding to your prompt +2. The Stop hook triggers and instructs Claude to run `/evolve-lite:learn` +3. The plugin analyzes the conversation trajectory +4. Extracts actionable entities from what worked/failed +5. Saves new entities as markdown files in `.evolve/entities/{type}/` + +You can also manually invoke `/evolve-lite:learn` at any time. + +> **UX note:** The Stop hook has an empty matcher (`""`), meaning it fires after *every* task and can add up to ~2 minutes of delay per interaction (the hook's `timeout` is 120s). It also invokes the Claude API on each stop, which incurs additional cost. Learned entities are stored as markdown files in `.evolve/entities/{type}/` — inspect or remove them there at any time. +> +> **To disable or limit automatic learning**, edit `hooks/hooks.json` inside the plugin directory: +> - Remove the entire `"Stop"` block to turn off auto-learning entirely. +> - Set a specific `"matcher"` string to restrict triggering to prompts that contain that text. +> - Reduce `"timeout"` to cap how long the learn step can run. + +## Sharing Guidelines + +Evolve Lite treats shared guidelines as multi-reader / multi-writer git +databases. A single unified `repos:` list in `evolve.config.yaml` describes +every external guideline repo you read from or publish to; each entry has a +`scope` of `read` (subscribe only) or `write` (publish target, also synced). + +### Setup + +Sharing requires an `evolve.config.yaml` at the project root. The subscribe +and publish skills will help you create one if it is missing. Structure: + +```yaml +identity: + user: yourname # used to stamp ownership on published guidelines +repos: + - name: memory + scope: write + remote: git@github.com:yourname/evolve-memory.git + branch: main + notes: public memory for my open-source projects + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org +sync: + on_session_start: true # auto-sync on each session start +``` + +- `scope: read` — pulled on sync. Cannot be published to. +- `scope: write` — publish target **and** pulled on sync (so you see + everything pushed to it, including by other writers). + +The `.evolve/` directory is kept out of version control — the skills +automatically add it to `.gitignore`. + +### Subscribing to a Repo + +Use `/evolve-lite:subscribe` to add either a read-only subscription or a +write-scope publish target: + +```text +/evolve-lite:subscribe +> Remote URL: git@github.com:alice/evolve-guidelines.git +> Short name: alice +> Scope: read +``` + +The repo is cloned into `.evolve/entities/subscribed/alice/` so recall can +pick it up immediately. Repo names must use only letters, numbers, `.`, +`_`, and `-`. + +### Publishing Guidelines + +Use `/evolve-lite:publish` to share one or more of your local guidelines +via a **write-scope** repo: + +1. The skill selects (or asks about) the write-scope target repo +2. It lists files in `.evolve/entities/guideline/` +3. You pick which ones to publish +4. Each selected file is moved into `.evolve/entities/subscribed/{repo}/guideline/`, + stamped with `visibility: public`, `owner`, `published_at`, and + `source`, committed, and pushed to the remote + +Because the publish target is also a subscribed repo, your next sync will +pull in anything other writers have pushed to the same repo. + +### Syncing Repos + +Use `/evolve-lite:sync` to pull the latest changes from every configured +repo (both scopes): + +```text +/evolve-lite:sync +> Synced 2 repo(s): memory [write] (+2 added, 0 updated, 0 removed), bob [read] (+0 added, 1 updated, 0 removed) +``` + +If `sync.on_session_start: true` is set in config, this runs automatically +at the start of each session. + +> **Note:** Read-scope repos use `git fetch` + `git reset --hard`, so the +> local clone always matches the remote exactly (deleted or modified files +> are restored). Write-scope repos use `git fetch` + `git rebase` so any +> unpushed local publish commits are preserved. + +### Removing a Repo + +Use `/evolve-lite:unsubscribe` to remove any repo and delete its local +clone. The skill shows scope and notes for each configured repo and warns +before removing a write-scope repo (unpushed publishes would be lost). + +### Sharing Storage Layout + +```text +.evolve/ + entities/ + guideline/ + private-guideline.md + subscribed/ + memory/ # write-scope clone — publishes land here + guideline/ + my-published-guideline.md + alice/ # read-scope clone + guideline/ + her-guideline.md +``` + +## Example Walkthrough + +See the [Evolve Lite guide](../../../../docs/integrations/claude/evolve-lite.md#example-walkthrough) for a step-by-step example showing the full learn-then-recall loop across two sessions. + +## Skills Included + +### `/evolve-lite:learn` + +Manually invoke to extract entities from the current conversation: +- Analyzes task, steps taken, successes and failures +- Generates proactive entities (what to do, not what to avoid) +- Outputs JSON that the save script persists as entity files + +### `/evolve-lite:recall` + +Manually invoke to retrieve and display stored entities. + +### `/evolve-lite:save` + +Manually invoke to capture successful workflows from your current session and save them as reusable skills: +- Analyzes conversation history (user requests, reasoning, tool calls, responses) +- Generates parameterized SKILL.md documentation +- Creates Python helper scripts for programmatic operations (when applicable) +- Saves to `~/.claude/skills/{skill-name}/` for cross-project availability + +**Quick Start:** +``` +User: [Complete a successful task] +User: "save" +Assistant: "What would you like to name this skill?" +User: "my-workflow-name" +``` + +### `/evolve-lite:save-trajectory` + +Manually invoke to export the current conversation as a trajectory JSON file: +- Converts all messages to OpenAI chat completion format (user, assistant, tool calls, tool results) +- Strips system reminders and cleans content +- Saves to `.evolve/trajectories/` with a timestamped filename +- Useful for trajectory analysis, fine-tuning data collection, and session review +- Runs in a forked context to keep the parent conversation clean + +## Entities Storage + +Entities are stored as individual markdown files in `.evolve/entities/`, nested by type: + +``` +.evolve/entities/ + guideline/ + use-python-pil-for-image-metadata-extraction.md + cache-api-responses-locally.md +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When extracting image metadata in containerized environments +--- + +Use Python PIL/Pillow for image metadata extraction in sandboxed environments + +## Rationale + +System tools like exiftool may not be available +``` + +## Environment Variables + +- `EVOLVE_DIR`: Override the default `.evolve` directory location (entities, trajectories, config, etc. are stored here) + +## Verification + +After installation, run `claude plugin list` to confirm the plugin is enabled. + +## Plugin Structure + +```text +evolve/ +├── .claude-plugin/ +│ └── plugin.json # Plugin manifest +├── skills/ +│ ├── learn/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── save_entities.py +│ ├── recall/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── retrieve_entities.py +│ ├── subscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── subscribe.py +│ ├── publish/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── publish.py +│ ├── sync/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── sync.py +│ ├── unsubscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── unsubscribe.py +│ ├── save/ +│ │ └── SKILL.md +│ └── save-trajectory/ +│ ├── SKILL.md +│ └── scripts/ +│ └── save_trajectory.py +├── hooks/ +│ └── hooks.json # Auto-configured hooks +└── README.md +``` diff --git a/plugin-source/_claude/hooks/hooks.json b/plugin-source/_claude/hooks/hooks.json new file mode 100644 index 00000000..1d282a7e --- /dev/null +++ b/plugin-source/_claude/hooks/hooks.json @@ -0,0 +1,41 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/recall/scripts/retrieve_entities.py" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/sync/scripts/sync.py --quiet" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/save-trajectory/scripts/on_stop.py" + }, + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/learn/scripts/on_stop.py" + } + ] + } + ] + } +} diff --git a/plugin-source/_claw-code/README.md b/plugin-source/_claw-code/README.md new file mode 100644 index 00000000..14ffa393 --- /dev/null +++ b/plugin-source/_claw-code/README.md @@ -0,0 +1,274 @@ +# Evolve Lite for Claw + +Evolve Lite for Claw is a small skill bundle for capturing and reusing lessons from work done in a project. In the Claw integration, it is skill-driven: you invoke the skills when you want to save guidance, recall guidance, export a trajectory, share guidelines with other projects or teammates, or turn a successful session into a reusable skill. + +It does not currently install or rely on active hooks as part of the documented workflow. + +⭐ Star the repo: https://github.com/AgentToolkit/altk-evolve + +## What This Plugin Provides + +After installation and enablement, this plugin gives Claw the following skills: + +Local capture and recall: + +- `evolve-lite:learn` analyzes the current conversation, extracts high-value guidelines, and saves them as markdown entities. +- `evolve-lite:recall` loads stored entities from the current project so the agent can review and apply the relevant ones. +- `evolve-lite:save-trajectory` exports the current conversation into an OpenAI-style trajectory JSON file. +- `evolve-lite:save` turns a successful session into a new reusable skill under Claw's skills directory. + +Sharing guidelines via git repos: + +- `evolve-lite:subscribe` adds a shared guidelines repo to the unified `repos:` list (read-scope subscription or write-scope publish target) and clones it under `.evolve/entities/subscribed/`. +- `evolve-lite:publish` moves a private guideline into a configured **write-scope** repo, stamps `visibility`/`owner`/`published_at`, and commits + pushes it. +- `evolve-lite:sync` pulls the latest changes from every configured repo (read-scope clones use `git fetch` + `git reset --hard`; write-scope clones use `git fetch` + `git rebase` to preserve unpushed publishes). +- `evolve-lite:unsubscribe` removes a configured repo and deletes its local clone, with an extra warning before removing a write-scope repo. + +The plugin is mainly a packaging and distribution mechanism for these skills and their helper scripts. + +## Installation + +Install the plugin with the project installer or by installing the plugin directory into Claw. + +If you use the project installer: + +```bash +./platform-integrations/install.sh install --platform claw-code +``` + +After installation: + +1. Open `claw` +2. Run `/plugin enable evolve-lite` +3. Run `/plugin list` to confirm it is enabled + +## Skill Guide + +### `evolve-lite:learn` + +Use this at the end of a task when the conversation exposed something worth remembering. + +What it does: + +- Reviews the current conversation in forked context +- Extracts up to five guidelines +- Focuses on shortcuts, error prevention, and user corrections +- Saves them into `.evolve/entities/` + +The helper script writes markdown files and deduplicates by normalized content. + +Stored format: + +```text +.evolve/entities/ + guideline/ + some-guideline.md +``` + +Each entity is stored as markdown with frontmatter such as: + +```markdown +--- +type: guideline +trigger: When working in sandboxed environments +--- + +Use Python libraries for this task instead of relying on unavailable system tools. + +## Rationale + +This avoids failures caused by missing host utilities. +``` + +### `evolve-lite:recall` + +Use this when you want the agent to review previously saved guidance before or during a task. + +What it does: + +- Loads all entity markdown files under `.evolve/entities/` +- Formats them into a readable prompt block +- Lets the agent decide which guidance is relevant + +This is a manual recall flow in the current Claw integration. The plugin README should not be read as implying automatic injection. + +### `evolve-lite:save-trajectory` + +Use this when you want a durable record of the current conversation for analysis, fine-tuning prep, or later guideline generation. + +What it does: + +- Walks the current conversation in forked context +- Converts it into an OpenAI chat-completions-style JSON structure +- Writes the result to `.evolve/trajectories/trajectory_.json` + +Output location: + +```text +.evolve/trajectories/ + trajectory_2026-04-10T12-00-00.json +``` + +### `evolve-lite:save` + +Use this after a successful session when you want to preserve the workflow itself as a reusable Claw skill. + +What it does: + +- Analyzes the successful session +- Extracts a reusable workflow +- Generates a new `SKILL.md` +- Optionally generates helper Python scripts +- Saves the result into Claw's skills directory + +Generated skills are stored under: + +- project-level: `.claw/skills//` when applicable +- user-level: `~/.claw/skills//` + +## Sharing Guidelines + +Evolve Lite treats shared guidelines as multi-reader / multi-writer git +databases. A single unified `repos:` list in `evolve.config.yaml` describes +every external guideline repo you read from or publish to; each entry has a +`scope` of `read` (subscribe only) or `write` (publish target, also synced). + +### Setup + +Sharing requires an `evolve.config.yaml` at the project root. The subscribe +and publish skills will help you create one if it is missing. Structure: + +```yaml +identity: + user: yourname # used to stamp ownership on published guidelines +repos: + - name: memory + scope: write + remote: git@github.com:yourname/evolve-memory.git + branch: main + notes: public memory for my open-source projects + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org +``` + +- `scope: read` — pulled on sync. Cannot be published to. +- `scope: write` — publish target **and** pulled on sync (so you see + everything pushed to it, including by other writers). + +The `.evolve/` directory is kept out of version control — the skills +automatically add it to `.gitignore`. + +### Subscribing, Publishing, Syncing, Unsubscribing + +- `evolve-lite:subscribe` adds either a read-only subscription or a + write-scope publish target. The repo is cloned into + `.evolve/entities/subscribed//` so recall can pick it up + immediately. Repo names must use only letters, numbers, `.`, `_`, and + `-`. +- `evolve-lite:publish` lists files in `.evolve/entities/guideline/`, + prompts for which to publish, then for each selection: stamps + `visibility: public`/`owner`/`published_at`/`source`, moves it under + `.evolve/entities/subscribed/{repo}/guideline/`, and commits + pushes. +- `evolve-lite:sync` pulls the latest changes from every configured repo. + Read-scope repos use `git fetch` + `git reset --hard`, so the local + clone always matches the remote exactly. Write-scope repos use + `git fetch` + `git rebase` so any unpushed local publish commits are + preserved. +- `evolve-lite:unsubscribe` removes any repo and deletes its local clone. + Warns before removing a write-scope repo (unpushed publishes would be + lost). + +### Sharing Storage Layout + +```text +.evolve/ + entities/ + guideline/ + private-guideline.md + subscribed/ + memory/ # write-scope clone — publishes land here + guideline/ + my-published-guideline.md + alice/ # read-scope clone + guideline/ + her-guideline.md +``` + +## Storage Locations + +This plugin uses a few simple storage locations: + +- `.evolve/entities/` for saved guidance entities +- `.evolve/trajectories/` for exported conversation trajectories +- `.claw/skills/` or `~/.claw/skills/` for installed/generated skills + +If `EVOLVE_DIR` is set, entity and trajectory storage follows that override instead of the default `.evolve/` directory. + +## Helper Scripts + +The bundled skills use small helper scripts: + +- `skills/learn/scripts/save_entities.py` saves entity JSON to markdown files +- `skills/recall/scripts/retrieve_entities.py` reads and formats stored entities +- `skills/save-trajectory/scripts/save_trajectory.py` writes trajectory JSON files +- `skills/subscribe/scripts/subscribe.py` clones a guidelines repo and registers it in `evolve.config.yaml` +- `skills/publish/scripts/publish.py` stamps and moves a private guideline into a write-scope clone +- `skills/sync/scripts/sync.py` pulls all configured repos (write-scope rebases preserve unpushed work) +- `skills/unsubscribe/scripts/unsubscribe.py` removes a repo from config and deletes its clone + +The Claw skill docs resolve these scripts from either: + +- `.claw/skills/...` +- `~/.claw/skills/...` + +so the skills work in both project-level and user-level installs. + +## Plugin Structure + +```text +evolve-lite/ +├── .claude-plugin/ +│ └── plugin.json +├── skills/ +│ ├── learn/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── save_entities.py +│ ├── recall/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── retrieve_entities.py +│ ├── save/ +│ │ └── SKILL.md +│ ├── save-trajectory/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── save_trajectory.py +│ ├── subscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── subscribe.py +│ ├── publish/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── publish.py +│ ├── sync/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── sync.py +│ └── unsubscribe/ +│ ├── SKILL.md +│ └── scripts/ +│ └── unsubscribe.py +├── lib/ +│ ├── __init__.py +│ ├── audit.py +│ ├── config.py +│ └── entity_io.py +├── hooks/ +│ └── retrieve_entities.sh +└── README.md +``` diff --git a/plugin-source/_claw-code/hooks/retrieve_entities.sh b/plugin-source/_claw-code/hooks/retrieve_entities.sh new file mode 100755 index 00000000..5963a3ff --- /dev/null +++ b/plugin-source/_claw-code/hooks/retrieve_entities.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Optional PreToolUse hook: retrieve relevant entities before tool execution. +# +# If this script is registered by a Claw hook configuration, it can inject +# stored guidelines and preferences from the evolve-lite entity store into +# context before tool calls. The packaged plugin does not currently enable +# this hook automatically — invoke `evolve-lite:recall` manually, or wire the +# hook in your own Claw configuration to opt in. +# +# When invoked as a PreToolUse hook, the following env vars are available: +# HOOK_EVENT - "PreToolUse" +# HOOK_TOOL_NAME - name of the tool about to run +# HOOK_TOOL_INPUT - JSON-encoded input for that tool + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="$(dirname "$SCRIPT_DIR")" + +# Feed the tool context into the entity retrieval script via stdin. +# The script reads it for logging; entity loading is path-based. +printf '%s' "${HOOK_TOOL_INPUT:-{}}" \ + | python3 "$PLUGIN_ROOT/skills/evolve-lite/recall/scripts/retrieve_entities.py" diff --git a/plugin-source/_codex/README.md b/plugin-source/_codex/README.md new file mode 100644 index 00000000..14a661e6 --- /dev/null +++ b/plugin-source/_codex/README.md @@ -0,0 +1,273 @@ +# Evolve Lite Plugin for Codex + +A plugin that helps Codex save, recall, and share reusable entities across workspaces. + +⭐ Star the repo: https://github.com/AgentToolkit/altk-evolve + +## Features + +- Automatic recall through a repo-level Codex `UserPromptSubmit` hook when Codex hooks are enabled +- Manual `evolve-lite:learn` skill to save reusable entities into `.evolve/entities/` +- Manual `evolve-lite:recall` skill to inspect everything stored for the current repo +- Manual `evolve-lite:publish` skill to publish private guidelines to your public repo +- Manual `evolve-lite:subscribe` and `evolve-lite:unsubscribe` skills to manage shared guideline repos +- Automatic or manual `evolve-lite:sync` to mirror subscribed repos into local recall storage + +## Storage + +Entities and sharing data are stored in the active workspace under: + +```text +.evolve/ + entities/ + guideline/ + use-context-managers-for-file-operations.md # private + subscribed/ + memory/ # write-scope clone (publish target) + guideline/ + my-published-guideline.md + alice/ # read-scope clone + guideline/ + prefer-small-functions.md + audit.log +``` + +Each entity is a markdown file with lightweight YAML frontmatter. + +Sharing configuration lives in `evolve.config.yaml` at the repo root, as a +single unified list of repos (both read- and write-scope): + +```yaml +identity: + user: alice + +repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve-memory.git + branch: main + notes: public memory for foobar project + - name: team + scope: read + remote: git@github.com:myorg/evolve-guidelines.git + branch: main + +sync: + on_session_start: true +``` + +## Source Layout + +This source tree intentionally omits `lib/`. + +The shared library lives in: + +```text +platform-integrations/claude/plugins/evolve-lite/lib/ +``` + +`platform-integrations/install.sh` installs Codex in this order: + +1. copy the Codex plugin source into `plugins/evolve-lite/` +2. copy the shared `lib/` from the Claude plugin into `plugins/evolve-lite/lib/` +3. wire the marketplace entry +4. wire the Codex hooks + +## Installation + +Use the platform installer from the repo root: + +```bash +platform-integrations/install.sh install --platform codex +``` + +That installs: + +- `plugins/evolve-lite/` +- `.agents/plugins/marketplace.json` +- `.codex/hooks.json` + +Automatic recall requires Codex hooks to be enabled in `~/.codex/config.toml`: + +```toml +[features] +codex_hooks = true +``` + +If you do not want to enable Codex hooks, you can still invoke the installed `evolve-lite:recall` skill manually to load or inspect the saved guidance for the current repo. + +The installed Codex hook does not require `git`. It walks upward from the current working directory until it finds the repo-local `plugins/evolve-lite/.../retrieve_entities.py` script. + +The installer always registers a `SessionStart` hook with matcher `startup|resume`; it runs on every Codex session start or resume and exits quickly unless `sync.on_session_start` is enabled and at least one repo is configured in `evolve.config.yaml`. + +## Sharing Guidelines + +Evolve Lite treats shared guidelines as multi-reader / multi-writer git +databases. A single unified `repos:` list in `evolve.config.yaml` describes +every external guideline repo; each entry has a `scope` of `read` (subscribe +only) or `write` (publish target that is also pulled on sync). + +### Setup + +Sharing uses `evolve.config.yaml` at the project root. Minimal structure: + +```yaml +identity: + user: yourname + +repos: + - name: memory + scope: write + remote: git@github.com:yourname/evolve-memory.git + branch: main + notes: public memory for my open-source projects + +sync: + on_session_start: true +``` + +The `.evolve/` directory is kept out of version control. + +### Subscribing to a Repo + +Use `evolve-lite:subscribe` to add either a read-only subscription or a +write-scope publish target. The repo is cloned directly into +`.evolve/entities/subscribed/{name}/` so recall picks it up immediately. +Names must use only letters, numbers, `.`, `_`, and `-`. + +### Publishing Guidelines + +Use `evolve-lite:publish` to share local guidelines via a **write-scope** +repo: + +1. The skill selects (or asks about) the write-scope target repo +2. Pick a file from `.evolve/entities/guideline/` +3. Publish moves it into `.evolve/entities/subscribed/{repo}/guideline/`, + stamps it with `visibility: public`, `published_at`, `owner`, and a + `source` label derived from the repo's remote +4. The original private guideline is removed from + `.evolve/entities/guideline/` + +Because the publish target is also a subscribed repo, your next sync pulls +in anything other writers have pushed to the same remote. + +### Syncing Repos + +Use `evolve-lite:sync` to pull the latest changes from every configured +repo (both scopes). Read-scope repos use `git fetch` + `git reset --hard`; +write-scope repos use `git fetch` + `git rebase` so unpushed local publish +commits are preserved. + +If `sync.on_session_start: true` is set in config, this runs automatically +whenever a Codex session starts or resumes. + +### Removing a Repo + +Use `evolve-lite:unsubscribe` to remove any configured repo and delete its +local clone at `.evolve/entities/subscribed/{name}/`. + +### Sharing Storage Layout + +```text +.evolve/ + entities/ + guideline/ + private-guideline.md # private local guideline + subscribed/ + memory/ # write-scope clone — publishes land here + guideline/ + my-published-guideline.md + alice/ # read-scope clone + guideline/ + her-guideline.md # recall annotates as [from: alice] +``` + +## Example Walkthrough + +See the [Codex example walkthrough](../../../../docs/examples/hello_world/codex.md) for a step-by-step example showing the save-then-recall loop in a Codex workspace. + +## Included Skills + +### `evolve-lite:learn` + +Analyze the current session and save proactive Evolve entities as markdown files. + +### `evolve-lite:recall` + +Show the entities already stored for the current workspace, including +guidelines pulled from any write- or read-scope repo under +`.evolve/entities/subscribed/`. + +### `evolve-lite:publish` + +Move a selected private guideline into a configured write-scope repo's +local clone at `.evolve/entities/subscribed/{repo}/guideline/`, stamp it +as public, commit it, and push it. + +### `evolve-lite:subscribe` + +Add an entry to the unified `repos:` list (read- or write-scope) and clone +the remote into `.evolve/entities/subscribed/{name}/`. + +### `evolve-lite:unsubscribe` + +Remove a configured repo from `repos:` and delete its local clone. + +### `evolve-lite:sync` + +Pull the latest from every configured repo (both scopes). Write-scope +repos use rebase to preserve unpushed local publish commits; read-scope +repos use hard reset to mirror the remote exactly. + +## Environment Variables + +- `EVOLVE_DIR`: Override the default `.evolve` directory location for entities, sharing data, audit logs, and the mirrored subscription store. + +## Verification + +After installation, verify that: + +- `plugins/evolve-lite/` exists in the repo +- `.agents/plugins/marketplace.json` contains the `evolve-lite` entry +- `.codex/hooks.json` contains the Evolve `UserPromptSubmit` and `SessionStart` hooks + +You can also run: + +```bash +platform-integrations/install.sh status +``` + +## Plugin Structure + +```text +evolve-lite/ +├── .codex-plugin/ +│ └── plugin.json +├── skills/ +│ ├── learn/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── save_entities.py +│ ├── recall/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── retrieve_entities.py +│ ├── publish/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── publish.py +│ ├── subscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── subscribe.py +│ ├── unsubscribe/ +│ │ ├── SKILL.md +│ │ └── scripts/ +│ │ └── unsubscribe.py +│ └── sync/ +│ ├── SKILL.md +│ └── scripts/ +│ └── sync.py +├── README.md +└── lib/ # copied in at install time from the Claude plugin +``` diff --git a/plugin-source/_macros.j2 b/plugin-source/_macros.j2 new file mode 100644 index 00000000..a3bc0ab2 --- /dev/null +++ b/plugin-source/_macros.j2 @@ -0,0 +1,61 @@ +{# Shared Jinja2 macros for plugin-source/. Imported by SKILL.md.j2 templates. #} + +{# `invoke` renders the platform-appropriate shell invocation of a skill script. + + skill — the skill folder name (e.g. "learn", "publish") + script — the script filename (e.g. "save_entities.py") + args — argument string OR list of args. None for no args. When a list is + passed, claude renders each arg on its own line with backslash + continuation (matches existing claude SKILL.md formatting); the + other platforms stay single-line because the whole command is + either wrapped in `sh -lc '...'` (claw-code) or invoked through + a single python3 call (codex, bob). + + Path resolution per platform: + claude — ${CLAUDE_PLUGIN_ROOT} expanded by the Claude plugin runtime. + claw-code — walks up from .claw/skills/ to a config-home fallback. + codex — git-rev-parse from any cwd inside the project clone. + bob — project-rooted .bob/skills/evolve-lite-/ (post-rename). +#} +{%- macro invoke(skill, script, args=None) -%} +{%- if platform == "claude" -%} +python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/{{ skill }}/scripts/{{ script }} +{%- if args is none %}{# no args; nothing appended #} +{%- elif args is string %} {{ args }} +{%- else %} \ + {{ args | join(" \\\n ") }} +{%- endif -%} +{%- elif platform == "claw-code" -%} +sh -lc 'real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:{{ skill }}/scripts/{{ script }}"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:{{ skill }}/scripts/{{ script }}"; python3 "$script" +{%- if args is none -%}{# no args; nothing appended -#} +{%- elif args is string %} {{ args }} +{%- else %} {{ args | join(" ") }} +{%- endif -%} +' +{%- elif platform == "codex" -%} +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/evolve-lite/{{ skill }}/scripts/{{ script }}" +{%- if args is none %}{# no args; nothing appended #} +{%- elif args is string %} {{ args }} +{%- else %} {{ args | join(" ") }} +{%- endif -%} +{%- elif platform == "bob" -%} +python3 .bob/skills/evolve-lite-{{ skill }}/scripts/{{ script }} +{%- if args is none %}{# no args; nothing appended #} +{%- elif args is string %} {{ args }} +{%- else %} {{ args | join(" ") }} +{%- endif -%} +{%- endif -%} +{%- endmacro -%} + +{# `skill_ref` renders the platform-appropriate way to refer to a sibling skill + in prose (e.g. "see /evolve-lite:sync" on claude, "evolve-lite:sync" on bob). + Bob uses the colon form to match its SKILL.md frontmatter `name` field + (e.g. `name: evolve-lite:sync`); the skill folder on disk stays + colon-free (`.bob/skills/evolve-lite-sync/`) for Windows compatibility. +#} +{%- macro skill_ref(name) -%} +{%- if platform in ["claude", "claw-code"] -%}/evolve-lite:{{ name }} +{%- elif platform == "codex" -%}evolve-lite:{{ name }} +{%- elif platform == "bob" -%}evolve-lite:{{ name }} +{%- endif -%} +{%- endmacro -%} diff --git a/plugin-source/build_plugins.py b/plugin-source/build_plugins.py new file mode 100644 index 00000000..d755e33b --- /dev/null +++ b/plugin-source/build_plugins.py @@ -0,0 +1,734 @@ +#!/usr/bin/env python3 +"""Render plugin-source/ into platform-integrations/. + +This script is the build pipeline for the unified plugin code tracked in +issue #219. It walks plugin-source/ — files fan out to every platform by +default — and emits the rendered tree under platform-integrations/. + +Per-platform configuration (plugin_root, Jinja context, optional path +rewrites, optional plugin.json metadata target) is encoded in the +PLATFORMS dict below. There is no separate manifest file; the file tree +under plugin-source/ IS the manifest, with these reserved entries that +live in plugin-source/ but are never shipped: + + _macros.j2 — imported by SKILL.md.j2 templates; not rendered standalone. + README.md — describes the source tree. + build_plugins.py — this script. + plugin.toml — canonical plugin metadata; projected to per-platform + plugin.json by metadata_emit functions, never copied. + +Per-platform routing: any file living under `plugin-source/_/...` +ships to that platform only, and the `_/` prefix is stripped from +its output target. This is how single-platform artifacts (claude's +`hooks/hooks.json`, bob's `custom_modes.yaml`, the per-platform READMEs) +live alongside the universal sources without leaking to other hosts. + +Source files ending in `.j2` are rendered through Jinja2 with a per-platform +context (see PlatformConfig.context). Other files are copied verbatim. + +Subcommands: + render — rewrite the managed files under platform-integrations/. + check — verify that committed platform-integrations/ matches a fresh + render of plugin-source/. Exits non-zero on drift; used by the + pre-commit hook and CI. +""" + +from __future__ import annotations + +import argparse +import filecmp +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from jinja2 import Environment, FileSystemLoader, StrictUndefined +from pydantic import BaseModel, ConfigDict, Field + +PLUGIN_SOURCE_DIR = Path(__file__).resolve().parent +REPO_ROOT = PLUGIN_SOURCE_DIR.parent + +# Files at plugin-source/ that are NOT shipped to any platform. +RESERVED_SOURCES = frozenset({"_macros.j2", "README.md", "build_plugins.py", "plugin.toml"}) + + +# ----- plugin.toml schema ---------------------------------------------------- +# +# Lenient pydantic models for plugin.toml. Only `name` and `version` under +# [plugin] are required; everything else has a sensible default. `extra="allow"` +# keeps unknown keys from raising — typos or platform tables we don't render +# yet pass through silently rather than breaking the build. + +_LENIENT = ConfigDict(extra="allow", populate_by_name=True) + + +class Author(BaseModel): + model_config = _LENIENT + name: str | None = None + + +class PluginUrls(BaseModel): + """Title-case keys in TOML (Homepage, Repository) per pyproject convention, + lowercase attributes in Python.""" + + model_config = _LENIENT + homepage: str | None = Field(default=None, alias="Homepage") + repository: str | None = Field(default=None, alias="Repository") + + +class Plugin(BaseModel): + model_config = _LENIENT + name: str + version: str + description: str | None = None + long_description: str | None = None + display_name: str | None = None + license: str | None = None + keywords: list[str] = [] + authors: list[Author] = [] + urls: PluginUrls = Field(default_factory=PluginUrls) + + +class ClaudeConfig(BaseModel): + """No declared fields today — claude-code's plugin.json is fully covered by + [plugin]. The table exists so users can land claude-only extras + (e.g. `commands`, `mcpServers`, `userConfig`) under [claude] and have them + flow into claude's plugin.json without leaking to other hosts.""" + + model_config = _LENIENT + + +class ClawCodeConfig(BaseModel): + model_config = _LENIENT + default_enabled: bool | None = None + + +class CodexConfig(BaseModel): + model_config = _LENIENT + category: str | None = None + capabilities: list[str] = [] + brand_color: str | None = None + default_prompt: list[str] = [] + + +class PluginMetadata(BaseModel): + """Top-level shape of plugin-source/plugin.toml. + + Per-host tables hold platform-specific fields. Anything a user adds to + [plugin] flows into every host's plugin.json top-level (host-agnostic + metadata like `commands`, `agents`, `mcpServers`, etc. per the + Claude Code plugin.json schema). Anything added to a host table flows + only into that host's output ([codex] extras land in the codex + `interface` block, where the rest of [codex] already lives).""" + + model_config = _LENIENT + plugin: Plugin + claude: ClaudeConfig = Field(default_factory=ClaudeConfig) + claw_code: ClawCodeConfig = Field(default_factory=ClawCodeConfig, alias="claw-code") + codex: CodexConfig = Field(default_factory=CodexConfig) + + +# ----- plugin.json output models -------------------------------------------- +# +# Per-platform output schemas. Field declaration order is the JSON key order; +# `serialization_alias` maps snake_case attributes to the camelCase JSON +# spelling each host expects. Optional fields default to None and are dropped +# at serialize time via `exclude_none=True`, so unset metadata vanishes from +# the rendered plugin.json without any explicit "skip if empty" plumbing. +# +# Output models share `_LENIENT` (extra="allow") with the input schema: +# host-side plugin.json schemas evolve, and we want a future caller to be able +# to pass an unknown kwarg (or load an unknown TOML field through the input +# model) and have it round-trip into the rendered JSON without code changes +# here. + +_SKILLS_PATH = "./skills/evolve-lite/" + + +class _OutAuthor(BaseModel): + model_config = _LENIENT + name: str + + +class _ClaudeOut(BaseModel): + model_config = _LENIENT + name: str + version: str + description: str | None = None + author: _OutAuthor | None = None + skills: str = _SKILLS_PATH + + +class _ClawCodeOut(BaseModel): + model_config = _LENIENT + name: str + version: str + description: str | None = None + author: _OutAuthor | None = None + default_enabled: bool | None = Field(default=None, serialization_alias="defaultEnabled") + skills: str = _SKILLS_PATH + + +class _CodexInterfaceOut(BaseModel): + model_config = _LENIENT + display_name: str | None = Field(default=None, serialization_alias="displayName") + short_description: str | None = Field(default=None, serialization_alias="shortDescription") + long_description: str | None = Field(default=None, serialization_alias="longDescription") + developer_name: str | None = Field(default=None, serialization_alias="developerName") + category: str | None = None + capabilities: list[str] | None = None + website_url: str | None = Field(default=None, serialization_alias="websiteURL") + default_prompt: list[str] | None = Field(default=None, serialization_alias="defaultPrompt") + brand_color: str | None = Field(default=None, serialization_alias="brandColor") + + def or_none(self) -> "_CodexInterfaceOut | None": + """Return self only when at least one field is populated; otherwise + None, so the interface block disappears from the rendered JSON.""" + return self if self.model_dump(exclude_none=True) else None + + +class _CodexOut(BaseModel): + model_config = _LENIENT + name: str + version: str + description: str | None = None + author: _OutAuthor | None = None + homepage: str | None = None + repository: str | None = None + license: str | None = None + keywords: list[str] | None = None + skills: str = _SKILLS_PATH + interface: _CodexInterfaceOut | None = None + + +# ----- projection ------------------------------------------------------------ +# +# Each platform that ships a plugin.json gets a small projection function that +# takes the validated PluginMetadata and returns its output model. The +# renderer serializes the model with `model_dump_json(by_alias=True, +# exclude_none=True, indent=2)`, which handles camelCase mapping and +# dropping-unset-fields uniformly. + +MetadataEmit = Callable[["PluginMetadata"], BaseModel] + + +def _extras(model: BaseModel) -> dict[str, Any]: + """The undeclared keys captured by `extra='allow'`. Empty dict if none.""" + return dict(model.__pydantic_extra__ or {}) + + +def _author(plugin: Plugin) -> _OutAuthor | None: + """Single-author hosts take authors[0]. Round-trips name plus any extra + author fields the user set (email, url, ...) via model_validate.""" + if not plugin.authors or not plugin.authors[0].name: + return None + return _OutAuthor.model_validate(plugin.authors[0].model_dump(exclude_none=True)) + + +def _claude_plugin_json(meta: PluginMetadata) -> _ClaudeOut: + p = meta.plugin + return _ClaudeOut( + name=p.name, + version=p.version, + description=p.description, + author=_author(p), + **_extras(p), + **_extras(meta.claude), + ) + + +def _claw_code_plugin_json(meta: PluginMetadata) -> _ClawCodeOut: + p = meta.plugin + return _ClawCodeOut( + name=p.name, + version=p.version, + description=p.description, + author=_author(p), + default_enabled=meta.claw_code.default_enabled, + **_extras(p), + **_extras(meta.claw_code), + ) + + +def _codex_plugin_json(meta: PluginMetadata) -> _CodexOut: + p = meta.plugin + c = meta.codex + return _CodexOut( + name=p.name, + version=p.version, + description=p.description, + author=_author(p), + homepage=p.urls.homepage, + repository=p.urls.repository, + license=p.license, + keywords=p.keywords or None, + interface=_CodexInterfaceOut( + display_name=p.display_name, + short_description=p.description, + long_description=p.long_description, + developer_name=p.authors[0].name if p.authors else None, + category=c.category, + capabilities=c.capabilities or None, + website_url=p.urls.homepage, + default_prompt=c.default_prompt or None, + brand_color=c.brand_color, + **_extras(c), + ).or_none(), + **_extras(p), + ) + + +# Per-platform config. Each entry declares where rendered output lands +# (plugin_root, relative to REPO_ROOT), the Jinja2 context exposed to +# .j2 templates, and any (regex, replacement) rewrites applied to a +# file's target path under that platform. +PLATFORMS: dict[str, dict[str, Any]] = { + "claude": { + "plugin_root": "platform-integrations/claude/plugins/evolve-lite", + "context": { + "forked_context": True, + "user_skills_dir": "~/.claude/skills", + "save_example_script_root": "${CLAUDE_PLUGIN_ROOT}/skills", + }, + "target_rewrites": [], + "target_excludes": [], + "metadata_target": ".claude-plugin/plugin.json", + "metadata_emit": _claude_plugin_json, + }, + "claw-code": { + "plugin_root": "platform-integrations/claw-code/plugins/evolve-lite", + "context": { + "user_skills_dir": "~/.claw/skills", + "save_example_script_root": "~/.claw/skills", + }, + "target_rewrites": [], + "target_excludes": [], + # claw-code is a claude-code fork that reuses the .claude-plugin/ convention. + "metadata_target": ".claude-plugin/plugin.json", + "metadata_emit": _claw_code_plugin_json, + }, + "codex": { + "plugin_root": "platform-integrations/codex/plugins/evolve-lite", + "context": { + "user_skills_dir": "plugins/evolve-lite/skills", + "save_example_script_root": "plugins/evolve-lite/skills", + }, + "target_rewrites": [], + "target_excludes": [], + "metadata_target": ".codex-plugin/plugin.json", + "metadata_emit": _codex_plugin_json, + }, + "bob": { + "plugin_root": "platform-integrations/bob/evolve-lite", + "context": { + "user_skills_dir": ".bob/skills", + "save_example_script_root": ".bob/skills", + }, + # Bob has no plugin-namespace concept; skill folders are flat + # under .bob/skills/. Collapse the source skills/evolve-lite// + # layout to skills/evolve-lite-/ for bob's render output. + "target_rewrites": [(r"^skills/evolve-lite/([^/]+)/", r"skills/evolve-lite-\1/")], + "target_excludes": [], + # Bob has no plugin system, so no plugin.json is emitted. Bob's + # commands/ directory is generated 1:1 from the skills walk by + # _bob_command_targets(); no static command files exist in + # plugin-source/. + "metadata_target": None, + "metadata_emit": None, + }, +} + + +# Bob's slash-command surface: one .md file per skill, generated from the +# skill folder name and its SKILL.md.j2 frontmatter `description`. Bob +# command frontmatter only honors `description` (and `argument-hints`, +# which our commands don't need); the slash-command identifier comes from +# the file name. The body references the skill by its on-disk folder name +# (`evolve-lite-`, dash form) — bob resolves skills by folder name, +# and folders stay colon-free for Windows compatibility. +_BOB_COMMAND_TEMPLATE = ( + "---\n" + "description: {description}\n" + "---\n" + "Use the `evolve-lite-{skill}` skill on the current conversation. Follow the skill's instructions exactly.\n" +) + + +def _discover_skills() -> list[Path]: + """Skill folders under plugin-source/skills/evolve-lite/ that ship a SKILL.md.j2.""" + skills_root = PLUGIN_SOURCE_DIR / "skills" / "evolve-lite" + return sorted(p for p in skills_root.iterdir() if p.is_dir() and (p / "SKILL.md.j2").is_file()) + + +def _read_skill_description(skill_dir: Path) -> str: + """Pull the single-line `description:` value from a skill's SKILL.md.j2 frontmatter.""" + text = (skill_dir / "SKILL.md.j2").read_text(encoding="utf-8") + match = re.search(r"^description:\s*(.+)$", text, re.MULTILINE) + if not match: + raise ValueError(f"missing `description` in {skill_dir.name}/SKILL.md.j2") + return match.group(1).strip() + + +def _bob_command_bytes(skill_dir: Path) -> bytes: + return _BOB_COMMAND_TEMPLATE.format( + skill=skill_dir.name, + description=_read_skill_description(skill_dir), + ).encode("utf-8") + + +def _bob_command_targets() -> list[tuple[Path, Path, bytes]]: + """Triples of (skill_source_for_drift_label, target_rel_to_repo_root, content) + for every bob command — one per skill — derived from the skills walk.""" + bob_root_rel = Path(PLATFORMS["bob"]["plugin_root"]) + out: list[tuple[Path, Path, bytes]] = [] + for skill_dir in _discover_skills(): + target_rel = bob_root_rel / "commands" / f"evolve-lite-{skill_dir.name}.md" + out.append((skill_dir / "SKILL.md.j2", target_rel, _bob_command_bytes(skill_dir))) + return out + + +@dataclass(frozen=True) +class TargetRewrite: + pattern: re.Pattern[str] + replacement: str + + +@dataclass(frozen=True) +class PlatformConfig: + plugin_root: Path + context: dict[str, Any] + target_rewrites: tuple[TargetRewrite, ...] = () + target_excludes: tuple[re.Pattern[str], ...] = () + metadata_target: Path | None = None + metadata_emit: MetadataEmit | None = None + + def rewrite_target(self, target_rel: Path) -> Path: + result = target_rel.as_posix() + for rewrite in self.target_rewrites: + result = rewrite.pattern.sub(rewrite.replacement, result) + return Path(result) + + def excludes(self, target_rel: Path) -> bool: + """True if this platform should skip rendering `target_rel`. + + Patterns match the source-side target path (before any rewrite), + so callers can write excludes against the plugin-source/ layout + without needing to know each platform's rewrite rules. + """ + s = target_rel.as_posix() + return any(p.search(s) for p in self.target_excludes) + + +@dataclass(frozen=True) +class FileEntry: + source: Path + target_rel: Path + platforms: tuple[str, ...] + + +@dataclass(frozen=True) +class Manifest: + platforms: dict[str, PlatformConfig] + files: tuple[FileEntry, ...] + + +def _platforms() -> dict[str, PlatformConfig]: + out: dict[str, PlatformConfig] = {} + for name, cfg in PLATFORMS.items(): + rewrites = tuple(TargetRewrite(pattern=re.compile(pat), replacement=repl) for pat, repl in cfg.get("target_rewrites", [])) + excludes = tuple(re.compile(pat) for pat in cfg.get("target_excludes", [])) + metadata_target = cfg.get("metadata_target") + out[name] = PlatformConfig( + plugin_root=REPO_ROOT / cfg["plugin_root"], + context=dict(cfg.get("context", {})), + target_rewrites=rewrites, + target_excludes=excludes, + metadata_target=Path(metadata_target) if metadata_target else None, + metadata_emit=cfg.get("metadata_emit"), + ) + return out + + +def _load_metadata() -> PluginMetadata: + """Parse and validate the canonical plugin.toml. Resolved against the live + PLUGIN_SOURCE_DIR so test monkeypatching of the module global works the + same way the source walk does.""" + with (PLUGIN_SOURCE_DIR / "plugin.toml").open("rb") as fp: + raw = tomllib.load(fp) + return PluginMetadata.model_validate(raw) + + +def _render_plugin_json(cfg: PlatformConfig, metadata: PluginMetadata) -> bytes: + assert cfg.metadata_emit is not None + model = cfg.metadata_emit(metadata) + return (model.model_dump_json(by_alias=True, exclude_none=True, indent=2) + "\n").encode("utf-8") + + +def _walk_sources() -> list[tuple[Path, tuple[str, ...]]]: + """Every source file paired with the platforms it ships to. + + Files under `plugin-source/_/...` ship to that single platform + only; everything else fans out to every platform. + + Excludes files in RESERVED_SOURCES at the source root, and any path + that traverses a __pycache__ directory (build_plugins.py running from + plugin-source/ writes a sibling __pycache__/ that must not ship). + """ + all_platforms = tuple(PLATFORMS.keys()) + platform_dirs = {f"_{name}": (name,) for name in PLATFORMS} + sources: list[tuple[Path, tuple[str, ...]]] = [] + for path in sorted(PLUGIN_SOURCE_DIR.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(PLUGIN_SOURCE_DIR) + if "__pycache__" in rel.parts: + continue + if len(rel.parts) == 1 and rel.parts[0] in RESERVED_SOURCES: + continue + platforms = platform_dirs.get(rel.parts[0], all_platforms) + sources.append((path, platforms)) + return sources + + +def _target_for(source: Path) -> Path: + """Per-platform target_rel before any rewrite — source path with the + leading `_/` prefix and any `.j2` suffix stripped.""" + rel = source.relative_to(PLUGIN_SOURCE_DIR) + if rel.parts and rel.parts[0].startswith("_") and rel.parts[0][1:] in PLATFORMS: + rel = Path(*rel.parts[1:]) + if rel.suffix == ".j2": + rel = rel.with_suffix("") + return rel + + +def load_manifest() -> Manifest: + platforms = _platforms() + files = tuple(FileEntry(source=src, target_rel=_target_for(src), platforms=plats) for src, plats in _walk_sources()) + return Manifest(platforms=platforms, files=files) + + +def _jinja_env() -> Environment: + return Environment( + loader=FileSystemLoader(str(PLUGIN_SOURCE_DIR)), + keep_trailing_newline=True, + undefined=StrictUndefined, + autoescape=False, + ) + + +def _render_template(env: Environment, source: Path, context: dict[str, Any]) -> bytes: + rel = source.relative_to(PLUGIN_SOURCE_DIR).as_posix() + template = env.get_template(rel) + rendered = template.render(**context) + return rendered.encode("utf-8") + + +def _is_template(path: Path) -> bool: + return path.suffix == ".j2" + + +def render_to(out_root: Path) -> list[Path]: + """Render every managed file into out_root//. + + out_root is the prefix; the per-platform plugin_root from PLATFORMS is + appended. For an in-place build, pass REPO_ROOT. + + Each platform's plugin_root under out_root is wiped before writing, so + files removed from plugin-source/ (renamed skills, deleted scripts, + obsolete commands) cannot linger as orphans in the rendered tree. + + Returns the list of paths written, relative to out_root. + """ + manifest = load_manifest() + for cfg in manifest.platforms.values(): + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) + target_root = out_root / plugin_root_rel + if target_root.exists(): + shutil.rmtree(target_root) + env = _jinja_env() + written: list[Path] = [] + for entry in manifest.files: + for platform in entry.platforms: + cfg = manifest.platforms[platform] + if cfg.excludes(entry.target_rel): + continue + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) + target_rel = cfg.rewrite_target(entry.target_rel) + target = out_root / plugin_root_rel / target_rel + target.parent.mkdir(parents=True, exist_ok=True) + if _is_template(entry.source): + ctx = {"platform": platform, **cfg.context} + target.write_bytes(_render_template(env, entry.source, ctx)) + else: + shutil.copy2(entry.source, target) + written.append(plugin_root_rel / target_rel) + + metadata = _load_metadata() + for platform, cfg in manifest.platforms.items(): + if cfg.metadata_target is None: + continue + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) + target = out_root / plugin_root_rel / cfg.metadata_target + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(_render_plugin_json(cfg, metadata)) + written.append(plugin_root_rel / cfg.metadata_target) + + for _, target_rel, content in _bob_command_targets(): + target = out_root / target_rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + written.append(target_rel) + return written + + +def _files_under_for_drift(root: Path) -> list[Path]: + """Files under `root` that participate in orphan checking, sorted. + + Prefers `git ls-files --cached --others --exclude-standard` so build + artifacts that match `.gitignore` (`__pycache__/*.pyc`, `.DS_Store`, + editor swap files, …) don't surface as false-positive orphans — + they're not managed by the render pipeline AND not tracked by git, + so they're correctly invisible to drift checking. + + Falls back to `Path.rglob` when the working tree isn't a git repo + (e.g. test fixtures running against a tmp_path), which surfaces + every file on disk so a deliberately seeded test orphan still trips + the check. + """ + try: + result = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "--", str(root)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + result = None + if result is not None and result.returncode == 0: + return sorted(REPO_ROOT / line for line in result.stdout.splitlines() if line) + return sorted(p for p in root.rglob("*") if p.is_file()) + + +def check_drift() -> int: + """Compare committed managed files against fresh-rendered content. + + Returns 0 only if every managed file matches its source AND no extra + (orphan) files exist under any plugin root. Returns 1 otherwise. + + Three classes of failure: + * missing — manifest expects a file that isn't on disk + * drift — committed bytes don't match a fresh render + * orphan — file exists on disk but no source path generates it + """ + manifest = load_manifest() + env = _jinja_env() + drifts: list[tuple[Path, Path]] = [] + missing: list[Path] = [] + expected: set[Path] = set() + for entry in manifest.files: + for platform in entry.platforms: + cfg = manifest.platforms[platform] + if cfg.excludes(entry.target_rel): + continue + committed = cfg.plugin_root / cfg.rewrite_target(entry.target_rel) + expected.add(committed) + if not committed.is_file(): + missing.append(committed) + continue + if _is_template(entry.source): + ctx = {"platform": platform, **cfg.context} + rendered = _render_template(env, entry.source, ctx) + if committed.read_bytes() != rendered: + drifts.append((entry.source, committed)) + else: + if not filecmp.cmp(entry.source, committed, shallow=False): + drifts.append((entry.source, committed)) + + plugin_toml = PLUGIN_SOURCE_DIR / "plugin.toml" + metadata = _load_metadata() + for platform, cfg in manifest.platforms.items(): + if cfg.metadata_target is None: + continue + committed = cfg.plugin_root / cfg.metadata_target + expected.add(committed) + if not committed.is_file(): + missing.append(committed) + continue + rendered = _render_plugin_json(cfg, metadata) + if committed.read_bytes() != rendered: + drifts.append((plugin_toml, committed)) + + for skill_src, target_rel, content in _bob_command_targets(): + committed = REPO_ROOT / target_rel + expected.add(committed) + if not committed.is_file(): + missing.append(committed) + continue + if committed.read_bytes() != content: + drifts.append((skill_src, committed)) + + # Orphan check: walk each plugin_root and flag any file that wasn't + # part of the expected render. Without this, a stale artifact left + # behind from a previous layout (or hand-edited bytes the render no + # longer emits) sails through `check` as if the tree were clean. + orphans: list[Path] = [] + for cfg in manifest.platforms.values(): + if not cfg.plugin_root.is_dir(): + continue + for path in _files_under_for_drift(cfg.plugin_root): + if path not in expected: + orphans.append(path) + + if missing or drifts or orphans: + for path in missing: + print(f"missing managed file: {path.relative_to(REPO_ROOT)}", file=sys.stderr) + for src, dst in drifts: + print( + f"drift: {dst.relative_to(REPO_ROOT)} differs from {src.relative_to(REPO_ROOT)}", + file=sys.stderr, + ) + for orphan in orphans: + print( + f"orphan: {orphan.relative_to(REPO_ROOT)} (not generated from plugin-source/)", + file=sys.stderr, + ) + print( + "\nrun `just compile-plugins` to regenerate, then commit the result.", + file=sys.stderr, + ) + return 1 + return 0 + + +def cmd_render(_: argparse.Namespace) -> int: + written = render_to(REPO_ROOT) + for path in written: + print(path) + return 0 + + +def cmd_check(_: argparse.Namespace) -> int: + return check_drift() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("render", help="render plugin-source/ into platform-integrations/") + sub.add_parser("check", help="verify committed output matches a fresh render") + args = parser.parse_args(argv) + if args.cmd == "render": + return cmd_render(args) + if args.cmd == "check": + return cmd_check(args) + parser.error(f"unknown command: {args.cmd}") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugin-source/lib/__init__.py b/plugin-source/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugin-source/lib/audit.py b/plugin-source/lib/audit.py new file mode 100644 index 00000000..fd5c535a --- /dev/null +++ b/plugin-source/lib/audit.py @@ -0,0 +1,33 @@ +"""Append-only audit log writer for .evolve/audit.log.""" + +import datetime +import json +import pathlib + + +def append(project_root=".", **fields): + """Append a JSON audit entry to .evolve/audit.log. + + Args: + project_root: Root directory that contains .evolve/. + **fields: Arbitrary key-value fields to include in the log entry. + """ + path = pathlib.Path(project_root) / ".evolve" / "audit.log" + path.parent.mkdir(parents=True, exist_ok=True) + entry = {**fields, "ts": datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z")} + with path.open("a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +if __name__ == "__main__": + import tempfile + + with tempfile.TemporaryDirectory() as d: + append(project_root=d, action="test", actor="alice") + log_path = __import__("pathlib").Path(d) / ".evolve" / "audit.log" + line = log_path.read_text(encoding="utf-8").strip() + entry = __import__("json").loads(line) + assert entry["action"] == "test" + assert entry["actor"] == "alice" + assert "ts" in entry + print("audit.py ok") diff --git a/plugin-source/lib/config.py b/plugin-source/lib/config.py new file mode 100644 index 00000000..4820494f --- /dev/null +++ b/plugin-source/lib/config.py @@ -0,0 +1,518 @@ +"""Shared config reader/writer for evolve.config.yaml (project root). + +pyyaml is not assumed to be installed. This module implements a minimal +YAML reader/writer that handles the flat and single-level-nested structures +used by evolve-lite config files (scalars and lists of scalar-valued dicts). +""" + +import pathlib +import re +import sys + + +VALID_SCOPES = ("read", "write") +_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$") + + +# --------------------------------------------------------------------------- +# Minimal YAML helpers (no pyyaml dependency) +# --------------------------------------------------------------------------- + + +def _strip_comments(line): + """Strip a YAML inline comment, preserving '#' inside single/double quotes.""" + quote = None + escape = False + for i, ch in enumerate(line): + if escape: + escape = False + continue + if quote: + if ch == "\\" and quote == '"': + escape = True + elif ch == quote: + quote = None + continue + if ch in ("'", '"'): + quote = ch + continue + if ch == "#": + return line[:i].rstrip() + return line.rstrip() + + +def _parse_block(lines, start, parent_indent): + """Parse an indented block starting at `start`. + + Returns (value, next_index) where value is either: + - a list (if block starts with '- ') + - a dict (if block contains 'key: value' pairs at the same indent) + + parent_indent is the indent level of the parent key line. + """ + i = start + # Peek ahead to determine type: list or mapping + # Skip blank lines first + while i < len(lines): + stripped = _strip_comments(lines[i]) + if stripped.strip(): + break + i += 1 + if i >= len(lines): + return {}, i + + first_content = _strip_comments(lines[i]) + block_indent = len(first_content) - len(first_content.lstrip()) + + if block_indent <= parent_indent: + # Nothing actually indented under this key + return {}, i + + if first_content.strip().startswith("- "): + # List + items = [] + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if content.startswith("- "): + item_text = content[2:].strip() + if ":" in item_text: + item_dict = {} + ik, _, iv = item_text.partition(":") + item_dict[ik.strip()] = _cast(iv.strip()) + i += 1 + # Collect more keys at deeper indent for this list item + while i < len(lines): + cont = _strip_comments(lines[i]) + if not cont.strip(): + i += 1 + continue + cont_indent = len(cont) - len(cont.lstrip()) + if cont_indent <= cur_indent: + break + cont_content = cont.strip() + if ":" in cont_content: + ck, _, cv = cont_content.partition(":") + item_dict[ck.strip()] = _cast(cv.strip()) + i += 1 + items.append(item_dict) + else: + items.append(_cast(item_text)) + i += 1 + else: + i += 1 + return items, i + else: + # Nested mapping + mapping = {} + while i < len(lines): + raw = _strip_comments(lines[i]) + if not raw.strip(): + i += 1 + continue + cur_indent = len(raw) - len(raw.lstrip()) + if cur_indent < block_indent: + break + content = raw.strip() + if ":" in content: + k, _, v = content.partition(":") + k = k.strip() + v = v.strip() + if v: + mapping[k] = _cast(v) + i += 1 + else: + # nested further — recurse + nested, i = _parse_block(lines, i + 1, cur_indent) + mapping[k] = nested + else: + i += 1 + return mapping, i + + +def _parse_yaml(text): + """Parse a minimal YAML subset into a Python dict. + + Supports: + - Top-level ``key: value`` scalar pairs + - Top-level ``key:`` with indented nested mappings or list items + - Comments (#) are stripped + """ + result = {} + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + stripped = _strip_comments(line) + if not stripped.strip(): + i += 1 + continue + indent = len(stripped) - len(stripped.lstrip()) + if indent > 0: + # Skip lines that belong to a block we already consumed + i += 1 + continue + key, sep, value = stripped.partition(":") + key = key.strip() + value = value.strip() + if not key: + i += 1 + continue + if value: + result[key] = _cast(value) + i += 1 + else: + # Block value (list or nested mapping) + block_val, i = _parse_block(lines, i + 1, 0) + result[key] = block_val + return result + + +def _cast(value): + """Cast a YAML scalar string to an appropriate Python type. + + Quoted scalars stay strings — that's the whole point of YAML quoting. + Only unquoted scalars get coerced to bool / null / int / float / list. + """ + # Quoted: return the string verbatim (with single-quote unescaping). + if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")): + stripped = value[1:-1] + if value.startswith("'"): + stripped = stripped.replace("''", "'") + return stripped + + if value in ("true", "True", "yes"): + return True + if value in ("false", "False", "no"): + return False + if value in ("null", "~", ""): + return None + try: + return int(value) + except ValueError: + pass + try: + return float(value) + except ValueError: + pass + # Empty list literal + if value == "[]": + return [] + return value + + +def _dump_yaml(obj, indent=0): + """Serialize a Python dict/list to a minimal YAML string.""" + lines = [] + prefix = " " * indent + if isinstance(obj, dict): + for k, v in obj.items(): + if isinstance(v, dict): + lines.append(f"{prefix}{k}:") + lines.extend(_dump_yaml(v, indent + 1).splitlines()) + elif isinstance(v, list): + if not v: + lines.append(f"{prefix}{k}: []") + continue + lines.append(f"{prefix}{k}:") + for item in v: + if isinstance(item, dict): + first = True + for ik, iv in item.items(): + if first: + lines.append(f"{prefix} - {ik}: {_scalar(iv)}") + first = False + else: + lines.append(f"{prefix} {ik}: {_scalar(iv)}") + else: + lines.append(f"{prefix} - {_scalar(item)}") + else: + lines.append(f"{prefix}{k}: {_scalar(v)}") + return "\n".join(lines) + + +def _scalar(v): + """Convert a Python value to a YAML scalar string, quoting when necessary.""" + if v is True: + return "true" + if v is False: + return "false" + if v is None: + return "null" + + # For non-string types, convert to string + if not isinstance(v, str): + return str(v) + + # Reserved YAML tokens that must be quoted + reserved_tokens = { + "true", + "True", + "TRUE", + "false", + "False", + "FALSE", + "null", + "Null", + "NULL", + "~", + "yes", + "Yes", + "YES", + "no", + "No", + "NO", + "on", + "On", + "ON", + "off", + "Off", + "OFF", + } + + # YAML indicator characters that require quoting + yaml_indicators = set("-?:[]{},'&*#!|>'\"%@`") + + # Check if quoting is needed + needs_quoting = ( + v in reserved_tokens # Reserved token + or v == "" # Empty string + or v[0] in " \t" + or v[-1] in " \t" # Leading/trailing whitespace + or "#" in v # Comment character + or any(c in yaml_indicators for c in v) # YAML special characters + or v[0] in yaml_indicators # Starts with indicator + ) + + if needs_quoting: + # Use single quotes and escape embedded single quotes by doubling them + escaped = v.replace("'", "''") + return f"'{escaped}'" + + return v + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def load_config(project_root="."): + """Read evolve.config.yaml from the project root and return a dict. + + Returns {} if the file does not exist. + """ + path = pathlib.Path(project_root) / "evolve.config.yaml" + if not path.exists(): + return {} + text = path.read_text(encoding="utf-8") + return _parse_yaml(text) + + +def save_config(cfg, project_root="."): + """Write *cfg* dict to evolve.config.yaml in the project root.""" + path = pathlib.Path(project_root) / "evolve.config.yaml" + content = _dump_yaml(cfg) + path.write_text(content + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Unified repo model (issue #217) +# --------------------------------------------------------------------------- + + +def _coerce_repo(entry): + """Normalize a single repo dict. Returns None if required fields are missing. + + Rejection is silent — callers that want to surface why a particular entry + was dropped should use ``classify_repo_entry`` to get the rejection reason + and report it however they choose. + """ + if not isinstance(entry, dict): + return None + name = entry.get("name") + remote = entry.get("remote") + if not isinstance(name, str) or not name.strip(): + return None + if not is_valid_repo_name(name.strip()): + print( + f"evolve-lite: {name!r} (skipped - invalid subscription name) — only A-Z, a-z, 0-9, '.', '_', '-' allowed", + file=sys.stderr, + ) + return None + if not isinstance(remote, str) or not remote.strip(): + return None + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name.strip(), + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + } + + +def normalize_repos(cfg): + """Return the unified ``repos`` list from *cfg* with invalid entries dropped. + + Invalid entries (missing ``name`` or ``remote``, duplicate names, unknown + scopes) are silently skipped so callers can trust every returned dict. + """ + if not isinstance(cfg, dict): + return [] + raw = cfg.get("repos") + if not isinstance(raw, list): + return [] + result = [] + seen = set() + for entry in raw: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + result.append(repo) + return result + + +def classify_repo_entry(entry): + """Return ``(repo, rejection)`` for one raw ``repos:`` list entry. + + Exactly one of ``repo`` or ``rejection`` is non-None: + - ``repo`` is the normalized dict (same shape as ``normalize_repos`` + items) when the entry is valid. + - ``rejection`` is a dict ``{"raw_name": str_or_None, "reason": str}`` + describing why the entry was dropped. ``reason`` is one of + "invalid subscription name", "missing remote", "unknown scope", or + "malformed entry". + + Used by sync.py (and similar) to surface skipped entries in user-facing + output without re-implementing validation. + """ + if not isinstance(entry, dict): + return None, {"raw_name": None, "reason": "malformed entry"} + raw_name = entry.get("name") + if not isinstance(raw_name, str) or not raw_name.strip(): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + name = raw_name.strip() + if not is_valid_repo_name(name): + return None, {"raw_name": raw_name, "reason": "invalid subscription name"} + remote = entry.get("remote") + if not isinstance(remote, str) or not remote.strip(): + return None, {"raw_name": raw_name, "reason": "missing remote"} + scope = entry.get("scope", "read") + if isinstance(scope, str): + scope = scope.strip() + if scope not in VALID_SCOPES: + return None, {"raw_name": raw_name, "reason": "unknown scope"} + branch = entry.get("branch", "main") + if not isinstance(branch, str) or not branch.strip(): + branch = "main" + notes = entry.get("notes", "") + if not isinstance(notes, str): + notes = "" + return { + "name": name, + "scope": scope, + "remote": remote.strip(), + "branch": branch.strip(), + "notes": notes, + }, None + + +def get_repo(cfg, name): + """Return the repo with the given name, or None.""" + for repo in normalize_repos(cfg): + if repo.get("name") == name: + return repo + return None + + +def write_repos(cfg): + """Return only the write-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "write"] + + +def read_repos(cfg): + """Return only the read-scope repos.""" + return [r for r in normalize_repos(cfg) if r.get("scope") == "read"] + + +def set_repos(cfg, repos): + """Replace the ``repos`` list in-place with sanitized entries.""" + if not isinstance(cfg, dict): + return cfg + sanitized = [] + seen = set() + for entry in repos or []: + repo = _coerce_repo(entry) + if repo is None or repo["name"] in seen: + continue + seen.add(repo["name"]) + sanitized.append(repo) + cfg["repos"] = sanitized + return cfg + + +def is_valid_repo_name(name): + """Return True if *name* is safe to use as a repo / directory name. + + Rejects leading '-' so names can't be confused with git CLI flags when + interpolated into clone paths. + """ + if not isinstance(name, str): + return False + if name in (".", "..") or name.startswith("-"): + return False + return bool(_SAFE_NAME.match(name)) + + +if __name__ == "__main__": + # Quick self-test + import tempfile + + with tempfile.TemporaryDirectory() as d: + cfg = { + "identity": {"user": "alice"}, + "repos": [ + { + "name": "memory", + "scope": "write", + "remote": "git@github.com:alice/evolve.git", + "branch": "main", + "notes": "public memory for foobar project", + }, + { + "name": "bob", + "scope": "read", + "remote": "git@github.com:bob/evolve.git", + "branch": "main", + "notes": "", + }, + ], + "sync": {"on_session_start": True}, + } + save_config(cfg, d) + loaded = load_config(d) + assert loaded["identity"]["user"] == "alice", loaded + assert loaded["sync"]["on_session_start"] is True, loaded + repos = normalize_repos(loaded) + assert len(repos) == 2, repos + assert repos[0]["scope"] == "write", repos + assert repos[1]["name"] == "bob", repos + print("config.py ok") diff --git a/plugin-source/lib/entity_io.py b/plugin-source/lib/entity_io.py new file mode 100644 index 00000000..b8e0eefa --- /dev/null +++ b/plugin-source/lib/entity_io.py @@ -0,0 +1,298 @@ +"""Shared entity I/O utilities for the Evolve plugin. + +Handles reading and writing entities as flat markdown files with YAML +frontmatter, organized in type-nested directories. +""" + +import datetime +import getpass +import os +import re +import tempfile +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def _get_log_dir(): + """Get user-scoped log directory with restrictive permissions.""" + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + return log_dir + + +_LOG_FILE = os.path.join(_get_log_dir(), "evolve-plugin.log") + + +def log(component, message): + """Append a timestamped message to the shared log file. + + Args: + component: Short label like "retrieve" or "save". + message: The log line. + """ + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [{component}] {message}\n") + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Directory discovery +# --------------------------------------------------------------------------- + + +def get_evolve_dir(): + """Return the .evolve root directory. + + Uses ``EVOLVE_DIR`` env var if set, otherwise ``.evolve/`` in cwd. + Does not create the directory. + """ + env_dir = os.environ.get("EVOLVE_DIR") + if env_dir: + return Path(env_dir) + return Path(".evolve") + + +def find_entities_dir(): + """Locate the entities directory. + + Uses :func:`get_evolve_dir` to determine the base directory, then + returns the ``entities/`` subdirectory Path if it exists, else ``None``. + """ + c = get_evolve_dir() / "entities" + return c if c.is_dir() else None + + +def find_recall_entity_dirs(): + """Locate all directories that should be searched during recall. + + Returns the existing recall roots. Only ``entities/`` is canonical — + private entities live in ``entities/guideline/`` and shared entities + live in ``entities/subscribed/{repo}/guideline/``. + """ + evolve_dir = get_evolve_dir() + candidates = [evolve_dir / "entities"] + return [path for path in candidates if path.is_dir()] + + +def get_default_entities_dir(): + """Return (and create) the default entities directory. + + Uses ``EVOLVE_DIR`` if set, falls back to ``.evolve/entities/``. + """ + base = get_evolve_dir() / "entities" + base.mkdir(parents=True, exist_ok=True) + return base.resolve() + + +# --------------------------------------------------------------------------- +# Slugify / filename helpers +# --------------------------------------------------------------------------- + + +def slugify(text, max_length=60): + """Convert *text* to a filesystem-safe slug. + + >>> slugify("Use temp files for JSON transfer!") + 'use-temp-files-for-json-transfer' + """ + text = text.lower() + text = re.sub(r"[^a-z0-9]+", "-", text) + text = text.strip("-") + # Truncate at max_length, but don't break in the middle of a word + if len(text) > max_length: + text = text[:max_length].rsplit("-", 1)[0] + return text or "entity" + + +def unique_filename(directory, slug): + """Return a Path that doesn't collide with existing files in *directory*. + + Tries ``slug.md``, then ``slug-2.md``, ``slug-3.md``, etc. + """ + directory = Path(directory) + candidate = directory / f"{slug}.md" + if not candidate.exists(): + return candidate + n = 2 + while True: + candidate = directory / f"{slug}-{n}.md" + if not candidate.exists(): + return candidate + n += 1 + + +# --------------------------------------------------------------------------- +# Markdown <-> dict conversion +# --------------------------------------------------------------------------- + +_FRONTMATTER_KEYS = ("type", "trigger", "trajectory", "owner", "source", "visibility", "published_at") + + +def entity_to_markdown(entity): + """Serialize an entity dict to markdown with YAML frontmatter. + + Args: + entity: dict with keys ``content``, and optionally ``type``, + ``trigger``, ``rationale``. + + Returns: + A string suitable for writing to a ``.md`` file. + """ + lines = ["---"] + for key in _FRONTMATTER_KEYS: + val = entity.get(key) + if val: + lines.append(f"{key}: {val}") + lines.append("---") + lines.append("") + + content = entity.get("content", "") + lines.append(content) + + rationale = entity.get("rationale") + if rationale: + lines.append("") + lines.append("## Rationale") + lines.append("") + lines.append(rationale) + + lines.append("") + return "\n".join(lines) + + +def markdown_to_entity(path): + """Parse a markdown entity file back into a dict. + + Handles YAML frontmatter with simple ``key: value`` lines (no nested + structures, no PyYAML dependency). + + Returns: + dict with ``content``, ``type``, ``trigger``, ``rationale`` keys. + """ + path = Path(path) + text = path.read_text(encoding="utf-8") + + entity = {} + + # Split frontmatter + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + frontmatter = parts[1].strip() + body = parts[2] + for line in frontmatter.splitlines(): + line = line.strip() + if not line: + continue + key, _, value = line.partition(":") + key = key.strip() + value = value.strip() + if key and value: + entity[key] = value + else: + body = text + else: + body = text + + # Split body into content and rationale + body = body.strip() + m = re.search(r"^## Rationale", body, re.MULTILINE) + if m: + content = body[: m.start()].strip() + rationale = body[m.end() :].strip() + if rationale: + entity["rationale"] = rationale + else: + content = body + + if content: + entity["content"] = content + + return entity + + +# --------------------------------------------------------------------------- +# Bulk load / write +# --------------------------------------------------------------------------- + + +def load_all_entities(entities_dir): + """Glob ``**/*.md`` under *entities_dir* and parse each file. + + Returns: + list of entity dicts. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(entities_dir.glob("**/*.md")): + try: + entity = markdown_to_entity(md) + if entity.get("content"): + entities.append(entity) + except OSError: + pass + return entities + + +def write_entity_file(directory, entity): + """Write a single entity as a markdown file under *directory*. + + The file is placed in a ``{type}/`` subdirectory. Uses atomic + write (write to ``.tmp``, then ``os.rename``). + + Returns: + Path to the written file. + """ + _ALLOWED_TYPES = {"guideline", "preference"} + entity_type = entity.get("type", "guideline") + if not isinstance(entity_type, str) or entity_type not in _ALLOWED_TYPES: + entity_type = "guideline" + entity["type"] = entity_type + type_dir = Path(directory) / entity_type + type_dir.mkdir(parents=True, exist_ok=True) + + slug = slugify(entity.get("content", "entity")) + content = entity_to_markdown(entity) + + # Write to a unique temp file first (avoids predictable .tmp collisions) + fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug) + target = None + try: + os.write(fd, content.encode("utf-8")) + os.close(fd) + fd = None + + # Atomically claim the target using O_EXCL; retry on race + while True: + target = unique_filename(type_dir, slug) + try: + claim_fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(claim_fd) + break + except FileExistsError: + continue + + os.replace(tmp_path, target) + return target + except BaseException: + if fd is not None: + os.close(fd) + if os.path.exists(tmp_path): + os.unlink(tmp_path) + # Clean up the 0-byte placeholder if the replace didn't happen + if target and os.path.exists(str(target)) and os.path.getsize(str(target)) == 0: + os.unlink(str(target)) + raise diff --git a/plugin-source/plugin.toml b/plugin-source/plugin.toml new file mode 100644 index 00000000..aa920e48 --- /dev/null +++ b/plugin-source/plugin.toml @@ -0,0 +1,57 @@ +# Canonical plugin metadata for the evolve-lite plugin. +# +# Per-platform plugin.json files under platform-integrations/ are rendered +# from this file by build_plugins.py. The [plugin] table holds host-agnostic +# metadata; per-platform tables hold genuinely platform-specific fields only. +# snake_case keys for declared fields are emitted as camelCase in the +# generated JSON. +# +# Only `name` and `version` are required. Every other field is optional and +# omitted from the rendered plugin.json when absent. +# +# Undeclared keys flow through too — write extras with the JSON spelling you +# want (e.g. `mcpServers = "./mcp.json"`, not `mcp_servers`): +# * extras under [plugin] flow to every host's plugin.json top-level +# * extras under [claude] / [claw-code] flow only to that host's top-level +# * extras under [codex] flow into codex's `interface` block +# See https://code.claude.com/docs/en/plugins-reference for the full Claude +# Code plugin.json schema (commands, agents, hooks, mcpServers, lspServers, +# monitors, userConfig, channels, dependencies, $schema, ...). + +[plugin] +name = "evolve-lite" +version = "1.1.0" +description = "Recall, save, and share reusable Evolve entities." +long_description = "A lightweight plugin that helps you save reusable entities, publish selected guidance, subscribe to shared repos, and recall relevant memory automatically on new prompts." +display_name = "Evolve Lite" +license = "MIT" +keywords = ["evolve", "memory", "entities"] +# Host plugin.json schemas accept a single author. Single-author hosts +# (claude, claw-code) take authors[0] — put your org/team first when you +# want the host to display the collective name. +authors = [ + { name = "AgentToolkit" }, + { name = "Vinod Muthusamy" }, +] + +[plugin.urls] +Homepage = "https://github.com/AgentToolkit/altk-evolve" +Repository = "https://github.com/AgentToolkit/altk-evolve" + +# [claude] is implicitly empty — claude-code's plugin.json has no fields +# beyond what [plugin] covers today. + +[claw-code] +default_enabled = true + +[codex] +category = "Productivity" +capabilities = ["Interactive", "Write"] +brand_color = "#2563EB" +default_prompt = [ + "Recall Evolve entities for this task.", + "Save new Evolve learnings from this session.", + "Show me the entities stored for this repo.", + "Publish one of my Evolve guidelines.", + "Subscribe to a teammate's Evolve guidelines repo.", +] diff --git a/plugin-source/skills/evolve-lite/learn/SKILL.md.j2 b/plugin-source/skills/evolve-lite/learn/SKILL.md.j2 new file mode 100644 index 00000000..8cfaa975 --- /dev/null +++ b/plugin-source/skills/evolve-lite/learn/SKILL.md.j2 @@ -0,0 +1,205 @@ +{%- from "_macros.j2" import invoke, skill_ref with context -%} +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}learn +description: Must be used near the end of any non-trivial turn that produced potentially reusable tools, guidance, errors, workarounds, or workflows, so those lessons are saved for future turns. +{% if platform == "claude" -%} +context: fork +{% endif -%} +--- + +# Entity Generator + +## Overview + +This skill analyzes the current conversation to extract actionable instructions that would help on similar tasks in the future. It **identifies errors encountered during the conversation** - tool failures, exceptions, wrong approaches, retry loops - and provides recommendations to prevent those errors from recurring. This skill should take note of the concrete solution which solved a concrete problem, not an abstract idea. When the successful resolution involves a non-trivial workaround, parser, command sequence, or fallback pipeline that could be used to avoid wasted effort, capture that solution as a reusable artifact first, then save entities that point future agents to use it. + +## When To Use + +Use this skill after completing meaningful work in the turn, especially when encountering: +- tool failures +- permission issues +- missing dependencies +- retries or abandoned approaches +- reusable command sequences or scripts + +Examples of artifacts that must be immediately created once proven as the successful solution include: +- an inline Python, shell, or other heredoc script +- a command assembled interactively over multiple retries +- a parser or extractor implemented ad hoc during the turn +- a fallback path triggered by missing dependencies or restricted tooling + +Unless that artifact happens to be: +- code which is a trivial one-liner that future agents would not benefit from reusing +- code which embeds secrets, tokens, or user-specific sensitive data +- a guideline that would instruct the agent to invoke a skill, tool, or external command by name (e.g. "run {{ skill_ref("learn") }}", "call save_trajectory") - such guidelines trigger prompt-injection detection when retrieved by the recall skill in a future session +- the user explicitly asked for a one-off result and not to persist helper code +- redundant because an equivalent local artifact on disk would be just as effective + +## Workflow + +{% if forked_context | default(false) -%} +### Step 0: Load the Conversation + +This skill runs in a forked context. **You cannot see the parent conversation directly** — the only way to access it is by reading the trajectory file the save-trajectory stop hook just wrote to disk. Do not infer from your own (empty) conversation that there's nothing to learn; the parent's real work is in that file. + +The stop-hook message (produced by `on_stop.py`) contains the literal marker `The saved trajectory path is: ` — a copy of the session transcript saved inside the project tree at `.evolve/trajectories/claude-transcript_.jsonl`. Take everything after the colon, strip surrounding whitespace and quotes, and use the result as `saved_trajectory_path`. You will also attach this exact path to each entity's `trajectory` field in Step 6. + +**Read this file with the `Read` tool — do NOT shell out.** `Read` pages large files natively (use its `offset` / `limit` parameters if needed). Do not use `cat`, `head`, `wc`, `find`, or `python3 -c` loops on the transcript — those trigger a permission prompt for every invocation and are unnecessary. + +If the saved trajectory file does not exist (e.g., the save-trajectory hook did not run, or no marker was provided), output zero entities and exit. Do NOT fall back to reading the live session transcript under `~/.claude/projects/` — that path is outside the project tree, triggers permission prompts, and may be larger than the fork can consume. + +The transcript is JSONL: each line is a separate JSON object. Filter for `"type": "assistant"` and `"type": "human"` lines, then reconstruct the flow from `message.content`. Look for tool calls, errors in tool results, and user corrections. + +{% endif -%} +### Step 1: Analyze the Conversation + +Identify from your current conversation{% if forked_context | default(false) %} (loaded from the transcript){% endif %}: + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches +- **Reusable Outcome**: Did the final working solution produce a reusable script, parser, command template, or workflow that would save time on a similar task? + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `jq: command not found` | System tool unavailable in environment | created a python script to resolve the problem | Save the python script and use it in similar scenarios | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +### Step 3: Decide Whether To Save The Pipeline + +Before writing entities, determine whether the successful approach should be saved as a reusable artifact. + +Create or update a local reusable artifact when any of these are true: +- the final solution required more than a trivial one-liner +- the final solution worked around missing tools, libraries, or permissions +- the solution is likely to recur on similar tasks + +Prefer one of these artifact forms: +- a small script, saved to a stable path in the workspace or plugin, such as `scripts/`, `tools/`, or another obvious helper location. +- a documented local workflow if code is not appropriate + +If you create an artifact, record: +- its path +- what it does +- when future agents should use it first + +### Step 4: Review Existing Guidelines + +Before extracting, look at what has already been saved for this project. Earlier Stop hooks in the same session (or prior sessions) may have recorded guidelines that cover the same ground — re-extracting them is wasteful and pollutes the library. + +Use the **Glob tool** to enumerate existing guideline files: `.evolve/entities/**/*.md`. Then use the **Read tool** to open each match and skim the content + trigger. + +**Do NOT use `cat`, `head`, `find`, a `for` loop, or an inline `python3 -c` script for this.** Each shell invocation triggers a permission prompt, and Glob + Read cover the same need without any prompting. + +If there are no existing guidelines, skip this step. + +With the existing-guideline set in mind, when you proceed to Step 5 you should pick only *complementary* findings — new angles, new failure modes, or finer-grained detail — and drop candidates that restate or near-duplicate anything already saved. (`save_entities.py` will also drop exact-match duplicates at write time, but it cannot catch re-wordings.) + +### Step 5: Extract Entities + +If Step 3 produced an artifact, at least one entity must explicitly point to that artifact, which is likely the only entity that needs to be produced. +Otherwise, extract 3-5 proactive entities. Prioritize entities derived from errors identified in Step 2. + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + - If an approach failed due to permissions, recommend the working permission-aware approach first + - If a system tool was unavailable, recommend the saved artifact or fallback workflow first + - If an approach hit environment constraints, recommend the constraint-aware approach + +2. **Prioritize known working local artifacts over general advice** + - If the successful solution produced or reused a concrete local artifact, at least one saved entity must: + - Bad: "Use Python to parse EXIF if exiftool is missing" + - Better: "Use `/abs/path/json_get.py` for JSON field extraction when `jq` is unavailable in minimal environments." + - name the artifact by path + - state exactly when to use it + - state that it should be tried before generic tool discovery or fallback exploration + - describe the artifact by capability, not just by the original incident + +3. **Triggers should describe the broad task context that the artifact solves, not the narrow details of the original request.** + - Bad trigger: "When jq fails" + - Good trigger: "When extracting fields from JSON in constrained shells or stripped-down environments" + The trigger should generalize the working solution without becoming vague. + +4. **For retry loops, recommend the final working approach as the starting point** + - Eliminate trial and error by creating a concrete local artifact out of the successful workflow or script + +5. **Prefer entities that save future time** + - A pointer to a saved working script is more valuable than a generic reminder if both are available + +### Step 6: Output Entities JSON + +Output entities in this JSON format. Include a `trajectory` field on every entity, set to the `saved_trajectory_path` extracted in Step 0 — this records which session produced the guideline. + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies", + "trajectory": ".evolve/trajectories/claude-transcript_.jsonl" + } + ] +} +``` + +Allowed type values: +- guideline +- workflow +- script +- command-template + +### Step 7: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe (Recommended) + +```bash +echo '' | {{ invoke("learn", "save_entities.py") }} +``` + +#### Method 2: From File + +```bash +cat entities.json | {{ invoke("learn", "save_entities.py") }} +``` + +#### Method 3: Interactive + +```bash +{{ invoke("learn", "save_entities.py") }} +``` + +The script will: +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices +1. Prioritize error-derived entities first. +2. One distinct error should normally produce one prevention entity. +3. Keep entities specific and actionable. +4. Include rationale so the future agent understands why the guidance matters. +5. Use situational triggers instead of failure-based triggers. +6. Limit output to the 3-5 most valuable entities. +7. If more than five distinct errors appear, merge entities with the same root cause or fix, then rank the rest by severity, frequency, user impact, and recency before dropping the weakest ones. diff --git a/plugin-source/skills/evolve-lite/learn/scripts/on_stop.py b/plugin-source/skills/evolve-lite/learn/scripts/on_stop.py new file mode 100644 index 00000000..d26afbcb --- /dev/null +++ b/plugin-source/skills/evolve-lite/learn/scripts/on_stop.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Stop hook that triggers the learn skill to extract guidelines.""" + +import json +import sys +from pathlib import Path + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + if input_data.get("stop_hook_active") is True: + return + + transcript_path = input_data.get("transcript_path", "") + reason = "Run the /evolve-lite:learn skill." + if transcript_path: + session_id = Path(transcript_path).stem.removeprefix("claude-transcript_") + if session_id: + saved_trajectory = f".evolve/trajectories/claude-transcript_{session_id}.jsonl" + reason += f" The saved trajectory path is: {saved_trajectory}" + + print( + json.dumps( + { + "decision": "block", + "reason": reason, + "suppressOutput": True, + "systemMessage": "Running the evolve-lite learn skill...", + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/learn/scripts/on_stop.sh b/plugin-source/skills/evolve-lite/learn/scripts/on_stop.sh new file mode 100755 index 00000000..b62b110c --- /dev/null +++ b/plugin-source/skills/evolve-lite/learn/scripts/on_stop.sh @@ -0,0 +1,15 @@ +#!/bin/bash +INPUT=$(cat) +STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active') + +# Guard against infinite loop — don't inject if already continuing from a stop hook +if [ "$STOP_HOOK_ACTIVE" = "true" ]; then + exit 0 +fi + +echo '{ + "decision": "block", + "reason": "Run the /evolve-lite:learn skill to extract guidelines from this conversation.", + "suppressOutput": true, + "systemMessage": "Running the evolve-lite learn skill..." +}' diff --git a/plugin-source/skills/evolve-lite/learn/scripts/save_entities.py b/plugin-source/skills/evolve-lite/learn/scripts/save_entities.py new file mode 100644 index 00000000..bd300f84 --- /dev/null +++ b/plugin-source/skills/evolve-lite/learn/scripts/save_entities.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Save Entities Script +Reads entities from stdin JSON and writes each as a markdown file +in the entities directory, organized by type. +""" + +import argparse +import json +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import ( # noqa: E402 + find_entities_dir, + get_default_entities_dir, + load_all_entities, + write_entity_file, + log as _log, +) + + +def log(message): + _log("save", message) + + +log("Script started") + + +def normalize(text): + """Normalize content for dedup comparison.""" + return " ".join(text.lower().split()) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--user", default=None, help="Stamp owner on every entity written") + args = parser.parse_args() + + try: + input_data = json.load(sys.stdin) + log(f"Received input with keys: {list(input_data.keys())}") + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + + new_entities = input_data.get("entities", []) + if not isinstance(new_entities, list): + log(f"Invalid entities payload type: {type(new_entities).__name__}") + print("Error: `entities` must be a list.", file=sys.stderr) + sys.exit(1) + if not new_entities: + log("No entities in input") + print("No entities provided in input.", file=sys.stderr) + sys.exit(0) + + log(f"Received {len(new_entities)} new entities") + + entities_dir = find_entities_dir() + if entities_dir: + entities_dir = entities_dir.resolve() + log(f"Found existing dir: {entities_dir}") + print(f"Using existing entities dir: {entities_dir}") + else: + entities_dir = get_default_entities_dir() + log(f"Created new dir: {entities_dir}") + print(f"Created new entities dir: {entities_dir}") + + existing_entities = load_all_entities(entities_dir) + existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} + log(f"Existing entities: {len(existing_entities)}") + + added_count = 0 + for entity in new_entities: + content = entity.get("content") + if not content: + log(f"Skipping entity without content: {entity}") + continue + if normalize(content) in existing_contents: + log(f"Skipping duplicate: {content[:60]}") + continue + + # Stamp owner and visibility from the script, never from stdin. + # Untrusted upstream input (a prompt-injected agent) must not be + # able to spoof either field, so unconditionally overwrite. + entity["owner"] = args.user or "unknown" + entity["visibility"] = "private" + + path = write_entity_file(entities_dir, entity) + existing_contents.add(normalize(content)) + added_count += 1 + log(f"Wrote: {path}") + + total = len(existing_entities) + added_count + log(f"Added {added_count} new entities. Total: {total}") + print(f"Added {added_count} new entity(ies). Total: {total}") + print(f"Entities stored in: {entities_dir}") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/publish/SKILL.md.j2 b/plugin-source/skills/evolve-lite/publish/SKILL.md.j2 new file mode 100644 index 00000000..5db35563 --- /dev/null +++ b/plugin-source/skills/evolve-lite/publish/SKILL.md.j2 @@ -0,0 +1,143 @@ +{%- from "_macros.j2" import invoke, skill_ref with context -%} +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}publish +description: Publish a private guideline to a configured write-scope repo. +--- + +# Publish a Guideline + +## Overview + +Publish one or more private guidelines from `.evolve/entities/guideline/` +into a configured **write-scope** repo. The entity is stamped with +`visibility: public`, `owner`, `published_at`, and `source`, moved into +the local clone of the write repo, and committed / pushed to the remote. + +The same local clone is also what `{{ skill_ref("sync") }}` pulls from — so you +and anyone else publishing to the same repo stay in sync. + +## Workflow + +### Step 1: Require a write-scope repo + +Read `evolve.config.yaml`. If no entry has `scope: write`, tell the user: + +> "You need at least one write-scope repo to publish to. Run {{ skill_ref("subscribe") }} with --scope write to set one up, then come back." + +Then stop. + +If `identity.user` is missing, ask for it and add it to the config. + +### Step 2: First-time setup + +Ensure `.evolve/` is gitignored at the project root: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 3: Pick the target write-scope repo + +Filter `repos:` to entries with `scope: write` (Step 1 already aborted if +there were zero, so at least one exists here). + +- Exactly one entry → use it as default. +- Multiple entries → show a numbered list with `notes` and ask which to publish to. + +Let `{repo}` be the chosen repo name and `{branch}` its configured branch (default `main`). + +### Step 4: List and select entities + +List files in `.evolve/entities/guideline/` and ask the user which to publish. + +### Step 5: Run publish script + +For each selected file, run: + +```bash +{{ invoke("publish", "publish.py", ['--entity "{filename}"', '--repo "{repo}"', '--user "{identity.user}"']) }} +``` + +### Step 6: Commit and push + +Build `{names}` as a comma-joined list of selected filenames, and +`{guideline_paths}` as a space-joined list of the corresponding +`guideline/{filename}` paths inside the clone (the files the publish +script just wrote). + +```bash +git -C ".evolve/entities/subscribed/{repo}" add -- {guideline_paths} +git -C ".evolve/entities/subscribed/{repo}" commit -m "[evolve] publish: {names}" +git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" +``` + +On push success, continue to Step 7. + +### Step 6a: Recover from non-fast-forward rejection + +If the push fails and stderr mentions `rejected` / `non-fast-forward` +/ `fetch first`, another writer pushed to `{branch}` in between. +Rebase the local commit and push once more: + +```bash +git -C ".evolve/entities/subscribed/{repo}" fetch origin "{branch}" +git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" +``` + +- Rebase clean → retry `git push origin "{branch}"` once, then Step 7. +- Rebase conflicted → attempt to resolve, then hand off for user + review. Do not `git rebase --continue` or `git push` without an + explicit user confirmation. + + 1. `git -C ".evolve/entities/subscribed/{repo}" status --porcelain` + lists the conflicted paths. If any are `UD`, `DU`, or binary, + skip to the abort step — those aren't safe to auto-resolve. + 2. For each `UU`/`AA` file, read the conflict markers. During a + rebase, `<<<<<<< HEAD` is the **remote's** version and the + section under the commit sha is the **publish change** being + replayed (opposite of a regular merge). Write an + intent-preserving resolution; don't `git add` yet. + 3. Show the user the diff (`git -C ".evolve/entities/subscribed/{repo}" diff HEAD -- {file}`) per + resolved file with a one-line strategy summary, and ask whether + to **continue** (stage + `rebase --continue` + push) or **abort** + (roll back for manual resolution). + 4. On **continue**: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" add {resolved-files} + git -C ".evolve/entities/subscribed/{repo}" rebase --continue + git -C ".evolve/entities/subscribed/{repo}" push origin "{branch}" + ``` + + Then Step 7. If `rebase --continue` surfaces a new conflict, loop + from step 1. + 5. On **abort** — user declined, conflict isn't safely resolvable, + or the proposed merge feels unsafe: + + ```bash + git -C ".evolve/entities/subscribed/{repo}" rebase --abort + ``` + + The local publish commit is preserved at + `.evolve/entities/subscribed/{repo}` but not on the remote. Tell + the user to either (a) resolve manually in that directory + (`git fetch origin {branch} && git rebase origin/{branch}`, fix + conflicts, `git add` + `git rebase --continue`, `git push origin + {branch}`) or (b) re-run `{{ skill_ref("publish") }}` with a different + filename if the conflict is a shared name. + +If the push fails for any other reason (auth, network, missing remote +ref), surface git's error and stop — rebase will not help. + +### Step 7: Confirm + +Tell the user what was published and to which repo. + +## Notes + +- Published entities are **moved** from `.evolve/entities/guideline/` into + the write-scope clone at `.evolve/entities/subscribed/{repo}/guideline/`, + with `visibility: public`, `owner: {user}`, `published_at`, and `source` + stamped in frontmatter +- The original private entity is deleted after successful publication +- All publish actions are logged to `.evolve/audit.log` diff --git a/plugin-source/skills/evolve-lite/publish/scripts/publish.py b/plugin-source/skills/evolve-lite/publish/scripts/publish.py new file mode 100755 index 00000000..cf1c128e --- /dev/null +++ b/plugin-source/skills/evolve-lite/publish/scripts/publish.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Publish a private guideline entity to a write-scope repo.""" + +import argparse +import datetime +import os +import re +import sys +import tempfile +from pathlib import Path, PurePath + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402 +from config import get_repo, load_config, normalize_repos, write_repos # noqa: E402 + + +def _resolve_source(repo, effective_user): + remote = repo.get("remote") if isinstance(repo, dict) else None + if isinstance(remote, str): + match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote) + if match: + return match.group(1) + return effective_user + + +def _select_target_repo(cfg, requested_name): + write = write_repos(cfg) + + if requested_name: + repo = get_repo(cfg, requested_name) + if repo is None: + available = ", ".join(r["name"] for r in normalize_repos(cfg)) or "(none)" + return None, f"no repo named '{requested_name}' is configured. Configured repos: {available}" + if repo.get("scope") != "write": + return None, f"repo '{requested_name}' has scope={repo.get('scope')!r}; publish requires scope=write" + return repo, None + + if not write: + return None, ("no write-scope repo configured. Run evolve-lite:subscribe with --scope write to set up a publish target.") + if len(write) > 1: + names = ", ".join(r["name"] for r in write) + return None, f"multiple write-scope repos configured; pick one with --repo. Available: {names}" + return write[0], None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--entity", required=True, help="Basename of the .md file to publish") + parser.add_argument("--user", default=None, help="Username to stamp as owner") + parser.add_argument("--repo", default=None, help="Write-scope repo name (optional if exactly one is configured)") + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir) if evolve_dir.name != ".evolve" else str(resolved_evolve_dir.parent) + + if PurePath(args.entity).name != args.entity or args.entity in {".", ".."}: + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + + src_base = (evolve_dir / "entities" / "guideline").resolve() + src_path = (evolve_dir / "entities" / "guideline" / args.entity).resolve() + + if not src_path.is_relative_to(src_base): + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + + if not src_path.is_file(): + print(f"Error: entity file not found or is a directory: {src_path}", file=sys.stderr) + sys.exit(1) + + config = load_config(project_root) + target, err = _select_target_repo(config, args.repo) + if err is not None: + print(f"Error: {err}", file=sys.stderr) + sys.exit(1) + + identity = config.get("identity", {}) + effective_user = args.user or (identity.get("user") if isinstance(identity, dict) else None) + + entity = markdown_to_entity(src_path) + entity["visibility"] = "public" + if effective_user: + entity["owner"] = effective_user + entity["published_at"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + source = _resolve_source(target, effective_user) + if source: + entity["source"] = source + + clone_root = evolve_dir / "entities" / "subscribed" / target["name"] + if not (clone_root / ".git").exists(): + print( + f"Error: target repo clone not found at {clone_root}. " + f"Run evolve-lite:subscribe with --scope write first, or evolve-lite:sync " + f"to clone it.", + file=sys.stderr, + ) + sys.exit(1) + dest_dir = clone_root / "guideline" + dest_dir.mkdir(parents=True, exist_ok=True) + dest_base = dest_dir.resolve() + dest_path = (dest_dir / args.entity).resolve() + if not dest_path.is_relative_to(dest_base): + print(f"Error: invalid entity name: {args.entity!r}", file=sys.stderr) + sys.exit(1) + if dest_path.exists(): + print(f"Error: already published: {dest_path}\nUnpublish it first or delete it manually.", file=sys.stderr) + sys.exit(1) + + temp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=dest_dir, + prefix=f".{args.entity}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(entity_to_markdown(entity)) + temp_file.flush() + os.fsync(temp_file.fileno()) + temp_path = Path(temp_file.name) + + temp_path.replace(dest_path) + src_path.unlink() + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + try: + audit_append( + project_root=project_root, + action="publish", + actor=effective_user or "unknown", + entity=args.entity, + repo=target["name"], + ) + except Exception as exc: + print(f"Warning: failed to append audit entry for publish: {exc}", file=sys.stderr) + + print(f"Published: {args.entity} -> {dest_path} (repo: {target['name']})") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 new file mode 100644 index 00000000..c2f84271 --- /dev/null +++ b/plugin-source/skills/evolve-lite/recall/SKILL.md.j2 @@ -0,0 +1,145 @@ +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}recall +description: Must be used at the start of any non-trivial task involving code changes, debugging, repo exploration, file inspection, or environment/tooling investigation to surface stored guidance before analysis or tool use. +{% if platform == "claude" -%} +context: fork +{% endif -%} +--- + +# Entity Retrieval + +## Overview + +This skill loads relevant stored Evolve entities into the current turn before substantive work begins. + +Use this skill first whenever the task involves: +- code changes +- debugging +- code review +- repo exploration +- file inspection +- environment/tooling investigation + +Skip only for trivial conversational requests with no local context. + +## Required Action + +Before any non-trivial local work, you must complete the recall workflow below. Reading this `SKILL.md` alone does not satisfy the skill. + +### Completion Rule + +Do not proceed to other analysis or tool use until all steps below are complete. + +1. Inspect `${EVOLVE_DIR:-.evolve}/entities/` for guidance relevant to the current task. +2. Read each matching entity file that appears relevant. +{% if forked_context | default(false) -%} +3. **Quote each matching entity verbatim in your final response** — include the full file contents (frontmatter, body, rationale, trigger). The parent agent does not see your intermediate Read tool results, so anything you do not quote in your final response is lost. +4. If no relevant entities exist, state that explicitly in your final response. +{%- else -%} +3. Summarize the applicable guidance in your own words before proceeding. +4. If no relevant entities exist, state that explicitly before proceeding. +{%- endif %} + +### Required Visible Completion Note + +Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms: + +{% if forked_context | default(false) -%} +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, quoted verbatim below` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` +{%- else -%} +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, read , applicable guidance: ` +- `Recall complete: searched ${EVOLVE_DIR:-.evolve}/entities/, no relevant entities found` +{%- endif %} + +### Minimum Acceptable Procedure + +1. List or search files under `${EVOLVE_DIR:-.evolve}/entities/`. +2. Identify candidate entities relevant to the task. +3. Open and read those entity files. +{% if forked_context | default(false) -%} +4. Quote each applicable entity's full file contents in your final response, or state that nothing applies. +{%- else -%} +4. Summarize what applies, or state that nothing applies. +{%- endif %} + +### Failure Conditions + +The skill is not complete if any of the following are true: + +- You only read this `SKILL.md` +- You did not inspect `${EVOLVE_DIR:-.evolve}/entities/` +- You did not read the relevant entity files +{% if forked_context | default(false) -%} +- You produced a final response without quoting any matched entity verbatim (or stating none applied) +{%- else -%} +- You proceeded without stating whether guidance was found +{%- endif %} + +## How It Works + +{% if platform == "claude" -%} +1. The Claude `UserPromptSubmit` hook fires before each user prompt is sent. +2. The helper script reads the prompt JSON from stdin. +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, + read-scope subscriptions, and write-scope publish targets which all + live under `entities/subscribed/{repo}/`). +4. It prints formatted guidance to stdout. +5. Claude adds that text as additional context for the turn. +{%- elif platform == "claw-code" -%} +1. The Claw-code `PreToolUse` hook fires before each tool call. +2. The helper script reads tool input from stdin (best-effort, ignored beyond logging). +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, + read-scope subscriptions, and write-scope publish targets which all + live under `entities/subscribed/{repo}/`). +4. It prints formatted guidance to stdout. +5. Claw-code adds that text as additional context for the turn. +{%- elif platform == "codex" -%} +1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent. +2. The helper script reads the prompt JSON from stdin. +3. It loads stored entities from `${EVOLVE_DIR:-.evolve}/entities/` (covers private, + read-scope subscriptions, and write-scope publish targets which all + live under `entities/subscribed/{repo}/`). +4. It prints formatted guidance to stdout. +5. Codex adds that text as extra developer context for the turn. + +If hooks are not enabled, complete the **Required Action** workflow above manually. +{%- elif platform == "bob" -%} +Bob has no auto-injection hook for entity retrieval. Complete the **Required Action** workflow above on every applicable task. + +Entities can come from multiple sources: +- **Private entities**: Your own local entities (not shared) +- **Subscribed entities**: Entities cloned from any configured repo — + read-scope subscriptions and write-scope publish targets both live + under `${EVOLVE_DIR:-.evolve}/entities/subscribed/{name}/` +{%- endif %} + +## Entities Storage + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md <- private + subscribed/ + memory/ <- write-scope clone (publishes land here) + guideline/ + my-published-guideline.md + alice/ <- read-scope clone + guideline/ + alice-guideline.md <- annotated [from: alice] +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` diff --git a/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py b/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py new file mode 100644 index 00000000..ade892fe --- /dev/null +++ b/plugin-source/skills/evolve-lite/recall/scripts/retrieve_entities.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for the agent to use as extra context.""" + +import json +import os +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for the agent to review. + + Entities that came from a subscribed source have their path recorded in + the private ``_source`` key (set by load_entities_with_source). These are + annotated with ``[from: {name}]`` so the agent knows their provenance. + """ + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + source = entity.get("_source") + if source: + content = f"[from: {source}] {content}" + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def load_entities_with_source(entities_dir): + """Load markdown entities from one recall root and annotate subscribed content. + + Symlinks and any files inside a ``.git`` directory are skipped so we don't + surface git's own bookkeeping or sneak past path validation when a write + -scope clone lives under entities/subscribed/{name}/. + """ + entities_dir = Path(entities_dir) + entities = [] + for md in sorted(p for p in entities_dir.glob("**/*.md") if ".git" not in p.parts): + if md.is_symlink(): + continue + try: + entity = markdown_to_entity(md) + except (OSError, UnicodeError): + continue + if not entity.get("content"): + continue + + entity.pop("_source", None) + parts = md.relative_to(entities_dir).parts + if parts and parts[0] == "subscribed" and len(parts) > 1: + entity["_source"] = parts[1] + + entities.append(entity) + + return entities + + +def main(): + # Hook context arrives via stdin as JSON when invoked from a hook + # (claude/claw-code/codex). Handle empty/absent stdin gracefully so the + # script also works when invoked manually (no hook upstream). + input_data = {} + try: + raw = sys.stdin.read() + if raw.strip(): + input_data = json.loads(raw) + if isinstance(input_data, dict): + log(f"Input keys: {list(input_data.keys())}") + else: + log(f"Input type: {type(input_data).__name__}") + else: + log("stdin was empty") + except json.JSONDecodeError as e: + log(f"stdin was not valid JSON ({e})") + return + + if isinstance(input_data, dict): + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + entities_dir = find_entities_dir() + log(f"Entities dir: {entities_dir}") + + entities = [] + if entities_dir: + entities = load_entities_with_source(entities_dir) + + if not entities: + log("No entities found") + return + + log(f"Loaded {len(entities)} entities") + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/save-trajectory/SKILL.md.j2 b/plugin-source/skills/evolve-lite/save-trajectory/SKILL.md.j2 new file mode 100644 index 00000000..5ce79420 --- /dev/null +++ b/plugin-source/skills/evolve-lite/save-trajectory/SKILL.md.j2 @@ -0,0 +1,151 @@ +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}save-trajectory +description: Save the current conversation as a trajectory JSON file in OpenAI chat completion format for analysis and fine-tuning +{% if platform == "claude" -%} +context: fork +{% endif -%} +--- + +# Save Trajectory + +## Overview + +This skill saves the current session's conversation history as a JSON file in OpenAI chat completion format. The trajectory is saved to `.evolve/trajectories/` in the project root. This enables trajectory analysis, fine-tuning data collection, and session review. + +## Workflow + +### Step 1: Walk Through Conversation Messages + +Review all messages in the current conversation from start to finish. For each message, identify its type: + +- **User text messages** +- **Assistant text responses** (may include thinking) +- **Assistant tool calls** +- **Tool results** + +### Step 2: Convert to OpenAI Chat Completion Format + +Convert each message to the appropriate format: + +**User text message:** +```json +{"role": "user", "content": "the user's message text"} +``` + +**Assistant text response (no thinking):** +```json +{"role": "assistant", "content": "the assistant's response text"} +``` + +**Assistant text response (with thinking):** +```json +{"role": "assistant", "content": "the assistant's response text", "thinking": "the thinking/reasoning text"} +``` + +**Assistant tool call (no visible text):** +```json +{ + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "tool_call_id_here", + "type": "function", + "function": { + "name": "ToolName", + "arguments": "{\"param\": \"value\"}" + } + } + ] +} +``` + +**Assistant tool call with text:** +```json +{ + "role": "assistant", + "content": "text before/after the tool call", + "tool_calls": [ + { + "id": "tool_call_id_here", + "type": "function", + "function": { + "name": "ToolName", + "arguments": "{\"param\": \"value\"}" + } + } + ] +} +``` + +**Tool result:** +```json +{"role": "tool", "tool_call_id": "tool_call_id_here", "content": "the tool output text"} +``` + +#### Important Details + +- **Tool call arguments must be a JSON string**, not a nested object. Use `json.dumps()` on the arguments object. +- **Tool call IDs**: Use the actual tool call ID from the conversation. If not available, generate a unique ID like `call_001`, `call_002`, etc. +- **Multiple tool calls**: If the assistant made multiple tool calls in one turn, include all of them in a single assistant message's `tool_calls` array, followed by separate tool result messages for each. +- **Thinking blocks**: If the assistant had both thinking and text in the same turn, combine them into one message with both `content` and `thinking` fields. + +### Step 3: Clean Content + +Strip `...` tags and their contents from all message content. Use a non-greedy multiline match (e.g., `re.sub(r'[\s\S]*?', '', text).strip()`). If after stripping, a message has empty content and no tool calls, omit it. + +### Step 4: Build Envelope + +Wrap the messages array in a trajectory envelope: + +```json +{ + "model": "", + "timestamp": "2025-01-15T10:30:00Z", + "messages": [...] +} +``` + +- **model**: Use the exact model ID from the current session's environment context (e.g., the value after "You are powered by the model named …"). Do not hardcode a default — always read it from the session. +- **timestamp**: Current ISO 8601 timestamp + +### Step 5: Save via Helper Script + +Write the trajectory JSON to a temporary file using the **Write** tool, then pass the file path to the helper script: + +1. Write the JSON to `.evolve/tmp/trajectory_input.json` using the Write tool (create the directory if needed) +2. Run the helper script with the file path as an argument: + +```bash +{% if platform == "claude" -%} +tmp=.evolve/tmp/trajectory_input.json; mkdir -p .evolve/tmp; trap 'rm -f "$tmp"' EXIT; python3 "${CLAUDE_PLUGIN_ROOT}/skills/save-trajectory/scripts/save_trajectory.py" "$tmp" +{%- elif platform == "claw-code" -%} +tmp=.evolve/tmp/trajectory_input.json; mkdir -p .evolve/tmp; trap 'rm -f "$tmp"' EXIT; real_home="$(python3 -c "import os,pwd; print(pwd.getpwuid(os.getuid()).pw_dir)")"; config_home="${CLAW_CONFIG_HOME:-$real_home/.claw}"; script=".claw/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py"; [ -f "$script" ] || script="$config_home/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py"; python3 "$script" "$tmp" +{%- elif platform == "bob" -%} +tmp=.evolve/tmp/trajectory_input.json; mkdir -p .evolve/tmp; trap 'rm -f "$tmp"' EXIT; python3 .bob/skills/evolve-lite-save-trajectory/scripts/save_trajectory.py "$tmp" +{%- endif %} +``` + +**Important**: Do NOT use inline Python scripts, heredocs, or stdin piping to pass the trajectory JSON. Always use the Write tool to create a temp file first. This avoids escaping issues with backslashes, quotes, and newlines in conversation content. + +The script will: +- Read the trajectory JSON from the provided file path +- Create the `.evolve/trajectories/` directory if needed +- Generate a timestamped filename (`trajectory_YYYY-MM-DDTHH-MM-SS.json`) +- Write the formatted JSON +- Print confirmation with file path and message count + +## Example Output + +After saving, you should see output like: + +```text +Trajectory saved: /path/to/project/.evolve/trajectories/trajectory_2025-01-15T10-30-00.json +Messages: 12 +``` + +## Notes + +- This skill captures what's visible in the current conversation context. Very long sessions may have earlier messages compressed or summarized by the system. Include these summarized messages as-is with `role: "user"` or `role: "assistant"` as appropriate — do not skip them, since they preserve the conversation flow. +- The trajectory format is compatible with OpenAI chat completion format for downstream tooling. +- Trajectories are saved per-project in `.evolve/trajectories/` and can be version-controlled or gitignored as preferred. diff --git a/plugin-source/skills/evolve-lite/save-trajectory/scripts/on_stop.py b/plugin-source/skills/evolve-lite/save-trajectory/scripts/on_stop.py new file mode 100644 index 00000000..81c3400e --- /dev/null +++ b/plugin-source/skills/evolve-lite/save-trajectory/scripts/on_stop.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Stop hook that copies the session transcript to .evolve/trajectories/.""" + +import datetime +import getpass +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory-stop] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def main(): + try: + input_data = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + input_data = {} + + log(f"Stop hook input keys: {list(input_data.keys())}") + log(f"Stop hook input: {json.dumps(input_data, default=str)[:2000]}") + + transcript_path = input_data.get("transcript_path") + if not transcript_path: + log("No transcript_path in stop hook input") + return + + src = Path(transcript_path) + if not src.is_file(): + log(f"Transcript file not found: {src}") + return + + session_id = src.stem + trajectories_dir = get_trajectories_dir() + dst = trajectories_dir / f"claude-transcript_{session_id}.jsonl" + + shutil.copy2(str(src), str(dst)) + log(f"Copied transcript {src} -> {dst}") + print(f"Trajectory saved: {dst}") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py b/plugin-source/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py new file mode 100755 index 00000000..f34571eb --- /dev/null +++ b/plugin-source/skills/evolve-lite/save-trajectory/scripts/save_trajectory.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +""" +Save Trajectory Script +Reads a trajectory JSON from a file path argument (or stdin as fallback) +and writes it to the .evolve/trajectories/ directory. +""" + +import datetime +import getpass +import json +import os +import sys +import tempfile +from pathlib import Path + + +_log_file = None + + +def _get_log_file(): + """Get log file path, lazily creating the log directory on first use.""" + global _log_file + if _log_file is None: + try: + uid = os.getuid() + except AttributeError: + uid = getpass.getuser() + log_dir = os.path.join(tempfile.gettempdir(), f"evolve-{uid}") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + _log_file = os.path.join(log_dir, "evolve-plugin.log") + return _log_file + + +def log(message): + """Append a timestamped message to the log file. Best-effort; never raises.""" + if not os.environ.get("EVOLVE_DEBUG"): + return + try: + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(_get_log_file(), "a", encoding="utf-8") as f: + f.write(f"[{timestamp}] [save-trajectory] {message}\n") + except Exception: + pass + + +def get_trajectories_dir(): + """Get the trajectories output directory, creating it if needed. + + Resolution order: + 1. ``EVOLVE_DIR`` env var (matches the documented contract) + 2. ``CLAUDE_PROJECT_ROOT`` env var (the agent's project root) + 3. ``.evolve/`` in the current working directory + """ + evolve_dir = os.environ.get("EVOLVE_DIR") + if evolve_dir: + base = Path(evolve_dir) / "trajectories" + else: + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") + if project_root: + base = Path(project_root) / ".evolve" / "trajectories" + else: + base = Path(".evolve") / "trajectories" + + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + +def open_trajectory_file(trajectories_dir): + """Atomically claim a timestamped trajectory file. + + Returns a ``(Path, fd)`` tuple. Uses ``O_CREAT | O_EXCL`` so two saves + racing within the same second pick distinct filenames instead of one + overwriting the other. + """ + now = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + base_name = f"trajectory_{now}" + + for suffix in range(0, 1000): + name = f"{base_name}.json" if suffix == 0 else f"{base_name}_{suffix}.json" + candidate = trajectories_dir / name + try: + fd = os.open(str(candidate), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + return candidate, fd + except FileExistsError: + continue + + raise RuntimeError(f"Too many trajectory files for timestamp {now}") + + +def main(): + # Read trajectory JSON from file argument or stdin + input_path = sys.argv[1] if len(sys.argv) > 1 else None + try: + if input_path: + log(f"Reading trajectory from file: {input_path}") + with open(input_path, "r", encoding="utf-8") as f: + trajectory = json.load(f) + else: + log("Reading trajectory from stdin") + trajectory = json.load(sys.stdin) + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + except OSError as e: + log(f"Failed to read input: {e}") + print(f"Error: Failed to read input - {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(trajectory, dict): + log(f"Expected JSON object, got {type(trajectory).__name__}") + print(f"Error: Expected JSON object, got {type(trajectory).__name__}", file=sys.stderr) + sys.exit(1) + + log(f"Received trajectory with keys: {list(trajectory.keys())}") + messages = trajectory.get("messages") + if not isinstance(messages, list) or not messages: + log(f"Invalid messages in trajectory: {type(messages).__name__}") + print("Error: `messages` must be a non-empty list.", file=sys.stderr) + sys.exit(1) + + log(f"Trajectory has {len(messages)} messages") + + # Atomically claim a unique output path (handles same-second races) + trajectories_dir = get_trajectories_dir() + output_path, fd = open_trajectory_file(trajectories_dir) + + # Write formatted JSON via the already-opened owner-only fd + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(trajectory, f, indent=2, default=str) + f.write("\n") + log(f"Wrote trajectory to {output_path}") + except OSError as e: + log(f"Failed to write trajectory: {e}") + print(f"Error: Failed to write file - {e}", file=sys.stderr) + sys.exit(1) + + print(f"Trajectory saved: {output_path}") + print(f"Messages: {len(messages)}") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/save/SKILL.md.j2 b/plugin-source/skills/evolve-lite/save/SKILL.md.j2 new file mode 100644 index 00000000..ee7beeda --- /dev/null +++ b/plugin-source/skills/evolve-lite/save/SKILL.md.j2 @@ -0,0 +1,473 @@ +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}save +description: Captures the current session's successful workflow and saves it as a reusable skill with SKILL.md and helper scripts +{% if platform == "claude" -%} +context: fork +{% endif -%} +--- + +# Save Session as Skill + +## Overview + +This skill analyzes your current successful session and generates a new reusable skill with: +- **SKILL.md**: Comprehensive documentation with workflow steps, parameters, and examples +- **Helper scripts**: Python scripts for any programmatic operations identified in the workflow + +It extracts the workflow pattern from your conversation history (user requests, reasoning steps, tool calls, and responses) and creates parameterized files that can be invoked in future sessions. + +Use this skill when you've completed a task successfully and want to save the workflow for future reuse. + +## When to Use + +- After completing a multi-step task successfully +- When you've discovered a useful workflow pattern +- When you want to standardize a process for future use +- After solving a problem that might recur +- When the workflow involves programmatic operations that could benefit from helper scripts + +## Workflow + +### Step 1: Review Current Session + +Analyze the conversation history available in the current context, which includes: + +- **User messages**: All requests and questions from the user +- **Assistant reasoning**: Thinking tags and decision-making process +- **Tool calls**: All tools invoked with their arguments +- **Tool responses**: Results and outcomes from each tool +- **Final outcome**: The successful result achieved + +**Action**: Review the entire conversation from start to current point + +### Step 2: Identify the Workflow Pattern + +Extract the high-level workflow by: + +1. **Identifying the goal**: What was the user trying to accomplish? +2. **Grouping related actions**: Which tool calls belong together as logical steps? +3. **Recognizing decision points**: Where did the workflow branch based on conditions? +4. **Noting error handling**: How were errors or edge cases handled? +5. **Extracting the sequence**: What is the step-by-step process? + +**Example Pattern Recognition**: +``` +User Goal: "Read a file and display its contents" + +Workflow Pattern: +1. Attempt to read file at expected location +2. If access denied → check allowed directories +3. Search for file in allowed directories +4. Read file from correct location +5. Format and present results +``` + +### Step 3: Identify Parameterizable Values + +Apply **conservative parameterization** - only parameterize obvious session-specific values: + +**Parameterize**: +- Absolute file paths → `{file_path}` or `{directory}` +- Specific file names → `{filename}` +- User-specific data → `{data_value}` +- Project-specific names → `{project_name}` +- Workspace directories → `{workspace_dir}` + +**Keep Unchanged**: +- Tool names (e.g., `read_file`, `execute_command`) +- General patterns and logic +- Error handling approaches +- Workflow structure + +**Example**: +``` +Original: "Read /home/user/projects/myapp/config.json" +Parameterized: "Read {project_dir}/{config_file}" +``` + +### Step 4: Identify Script Opportunities + +Analyze the workflow to determine if helper scripts would be beneficial: + +**Generate scripts when the workflow includes**: +- Data transformation or parsing (JSON, CSV, XML processing) +- File operations (reading, writing, searching, filtering) +- API calls or HTTP requests +- Complex calculations or data analysis +- Repetitive operations that could be automated +- Integration with external tools or services + +**Script Types to Consider**: +- **Data processors**: Parse, transform, or validate data +- **File handlers**: Read, write, or manipulate files +- **API clients**: Interact with external services +- **Validators**: Check inputs or outputs +- **Formatters**: Convert data between formats + +**Example**: +``` +Workflow includes: Reading JSON file, extracting specific fields, formatting output +→ Generate: parse_and_format.py script +``` + +### Step 5: Generate Skill Document + +Create a new SKILL.md file with the following structure: + +```markdown +--- +name: {skill-name} +description: {one-line description of what this skill does} +--- + +# {Skill Title} + +## Overview + +{Brief description of the skill's purpose and when to use it} + +## Parameters + +{List parameters the user needs to provide} + +- **{param_name}**: {description and example} + +## Workflow + +### Step 1: {Step Name} + +{What this step does} + +**Action**: {Tool or approach to use} + +**Example**: +``` +{Example tool call or command} +``` + +{If helper script exists, reference it} +**Helper Script**: Use `scripts/{script_name}.py` for this operation + +{Repeat for each step} + +## Helper Scripts + +{If scripts were generated, document them} + +### {script_name}.py + +**Purpose**: {What the script does} + +**Usage**: +```bash +python3 {{ save_example_script_root }}/{skill-name}/scripts/{script_name}.py [arguments] +``` + +**Parameters**: +- `{param}`: {description} + +**Example**: +```bash +python3 {{ save_example_script_root }}/{skill-name}/scripts/parse_data.py input.json +``` + +## Error Handling + +{Common errors and how to handle them} + +## Examples + +### Example 1: {Use Case} + +**Input**: +- {param}: {value} + +**Expected Output**: +{What the user should see} + +## Notes + +{Additional guidelines or context} +``` + +### Step 6: Generate Helper Scripts + +For each identified script opportunity, create a Python script with: + +**Script Template**: +```python +#!/usr/bin/env python3 +""" +{Script description} + +Usage: + python3 {script_name}.py [arguments] + +Arguments: + {arg1}: {description} + {arg2}: {description} +""" + +import sys +import json +import argparse +from pathlib import Path + + +def main(): + """Main function implementing the script logic.""" + parser = argparse.ArgumentParser(description="{Script description}") + parser.add_argument("{arg1}", help="{description}") + parser.add_argument("{arg2}", help="{description}", nargs="?") + + args = parser.parse_args() + + # Implementation based on workflow pattern + try: + # Core logic here + result = process_data(args.{arg1}) + print(json.dumps(result, indent=2)) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def process_data(input_data): + """Process the input data according to the workflow pattern.""" + # Implementation extracted from session workflow + pass + + +if __name__ == "__main__": + main() +``` + +**Script Guidelines**: +- Include proper error handling +- Accept parameters via command-line arguments +- Output results in a structured format (JSON when appropriate) +- Include usage documentation in docstring +- Make scripts executable (`chmod +x`) + +### Step 7: Prompt for Skill Name + +Ask the user: **"What would you like to name this skill?"** + +**Naming Guidelines**: +- Use lowercase letters +- Separate words with hyphens (kebab-case) +- Be descriptive but concise +- Examples: `read-file-with-permissions`, `deploy-to-staging`, `analyze-logs` + +**Suggest a name** based on the workflow if the user is unsure: +- Extract key actions and objects from the workflow +- Combine into a descriptive name +- Example: "Read file → Check permissions → Search" → `read-file-with-permission-check` + +### Step 8: Check for Existing Skill + +Before saving, check if a skill with this name already exists: + +**Action**: Check if `{{ user_skills_dir }}/{skill-name}/SKILL.md` exists + +**If exists**: +- Inform the user +- Ask: "A skill with this name already exists. Would you like to:" + - Overwrite the existing skill + - Choose a different name + - Cancel + +### Step 9: Save the Skill + +**Action**: Create the skill directory structure and save all files + +1. Create directory: `{{ user_skills_dir }}/{skill-name}/` +2. Write SKILL.md to: `{{ user_skills_dir }}/{skill-name}/SKILL.md` +3. If scripts were generated: + - Create directory: `{{ user_skills_dir }}/{skill-name}/scripts/` + - Write each script to: `{{ user_skills_dir }}/{skill-name}/scripts/{script_name}.py` + - Make scripts executable: `chmod +x {{ user_skills_dir }}/{skill-name}/scripts/*.py` +4. Ensure proper permissions (readable by user) + +**Directory Structure**: +``` +{{ user_skills_dir }}/{skill-name}/ +├── SKILL.md +└── scripts/ (if applicable) + ├── script1.py + └── script2.py +``` + +**Note**: The skill is saved to the user's home directory (`{{ user_skills_dir }}/`) making it available across all projects. + +### Step 10: Provide Summary + +Present a clear summary to the user: + +``` +✅ Skill saved successfully! + +**Skill Name**: {skill-name} +**Location**: {{ user_skills_dir }}/{skill-name}/ + +**Files Created**: +- SKILL.md (workflow documentation) +{if scripts} +- scripts/{script1}.py (helper script for {purpose}) +- scripts/{script2}.py (helper script for {purpose}) +{endif} + +**Summary**: {Brief description of what the skill does} + +**Workflow Captured**: +1. {Step 1 summary} +2. {Step 2 summary} +3. {Step 3 summary} +... + +**Parameters**: +- **{param1}**: {description} +- **{param2}**: {description} + +**Helper Scripts**: +{if scripts} +- **{script1}.py**: {what it does} +- **{script2}.py**: {what it does} +{endif} + +**To use this skill**: Simply reference it by name in future sessions: "{skill-name}" +``` + +## Error Handling + +**Session Too Short**: +- If the session has fewer than 3 meaningful exchanges, inform the user +- Suggest completing more of the task before saving as a skill + +**No Clear Workflow**: +- If the conversation doesn't show a clear workflow pattern, ask the user to clarify +- Request: "Could you describe the key steps you want to capture?" + +**Skill Name Conflicts**: +- If the name already exists, provide options (overwrite, rename, cancel) +- Never silently overwrite without user confirmation + +**Invalid Skill Name**: +- If the name contains invalid characters (spaces, special chars), suggest corrections +- Example: "My Skill!" → "my-skill" + +**Script Generation Errors**: +- If script generation fails, save the SKILL.md anyway +- Inform user they can add scripts manually later +- Provide guidance on what the script should do + +## Examples + +### Example 1: Saving a File Reading Workflow (with script) + +**Session Context**: +``` +User: "Read the states.txt file and parse it into a JSON array" +Assistant: [Reads file, parses lines, converts to JSON, outputs result] +User: "Great! Save this as a skill" +``` + +**Generated Skill Name**: `read-and-parse-file` + +**Parameters Identified**: +- `filename`: The file to read +- `output_format`: Format for output (json, csv, etc.) + +**Workflow Captured**: +1. Read file from workspace +2. Parse file contents line by line +3. Convert to specified format +4. Output formatted result + +**Scripts Generated**: +- `parse_file.py`: Reads a file and converts it to JSON format + +**Files Created**: +``` +{{ user_skills_dir }}/read-and-parse-file/ +├── SKILL.md +└── scripts/ + └── parse_file.py +``` + +### Example 2: Saving a Deployment Workflow (with multiple scripts) + +**Session Context**: +``` +User: "Deploy the app to staging" +Assistant: [Runs tests, builds app, uploads to server, restarts service] +User: "Perfect! Save this workflow" +``` + +**Generated Skill Name**: `deploy-to-staging` + +**Parameters Identified**: +- `app_name`: Name of the application +- `server_address`: Staging server address + +**Workflow Captured**: +1. Run test suite +2. Build application +3. Upload to staging server +4. Restart service +5. Verify deployment + +**Scripts Generated**: +- `run_tests.py`: Execute test suite and report results +- `deploy.py`: Handle upload and service restart + +**Files Created**: +``` +{{ user_skills_dir }}/deploy-to-staging/ +├── SKILL.md +└── scripts/ + ├── run_tests.py + └── deploy.py +``` + +### Example 3: Simple Workflow (no scripts needed) + +**Session Context**: +``` +User: "List all Python files in the project" +Assistant: [Uses glob tool to find *.py files, displays results] +User: "Save this" +``` + +**Generated Skill Name**: `list-python-files` + +**Workflow Captured**: +1. Use glob tool with pattern "**/*.py" +2. Format and display results + +**Scripts Generated**: None (simple tool call, no script needed) + +**Files Created**: +``` +{{ user_skills_dir }}/list-python-files/ +└── SKILL.md +``` + +## Notes + +- **Conservative Parameterization**: Only obvious session-specific values are parameterized. You can manually edit the generated skill later for more customization. +- **Cross-Project Availability**: Skills are saved to `{{ user_skills_dir }}/` making them available in all your projects. +- **Manual Editing**: After generation, you can manually edit the SKILL.md file and scripts to refine the workflow, add more examples, or adjust parameters. +- **Script Reusability**: Generated scripts can be used standalone or called from other scripts. +- **Skill Composition**: Generated skills can reference other skills, creating powerful workflow chains. +- **Version Control**: Consider adding your `{{ user_skills_dir }}/` directory to version control to track skill evolution. + +## Guidelines for Better Skills + +1. **Complete the task first**: Make sure your workflow is successful before saving it as a skill +2. **Clear session**: The clearer your session workflow, the better the generated skill and scripts +3. **Descriptive names**: Choose skill names that clearly indicate what they do +4. **Test scripts**: After generation, test the helper scripts to ensure they work correctly +5. **Add context**: After generation, consider adding more examples or notes to the skill +6. **Refine scripts**: Review generated scripts and add error handling or features as needed +7. **Document parameters**: Ensure all script parameters are well-documented in both SKILL.md and script docstrings diff --git a/plugin-source/skills/evolve-lite/subscribe/SKILL.md.j2 b/plugin-source/skills/evolve-lite/subscribe/SKILL.md.j2 new file mode 100644 index 00000000..e7f1c792 --- /dev/null +++ b/plugin-source/skills/evolve-lite/subscribe/SKILL.md.j2 @@ -0,0 +1,80 @@ +{%- from "_macros.j2" import invoke, skill_ref with context -%} +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}subscribe +description: Add a shared guidelines repo (read-scope subscription or write-scope publish target) to the unified repos list. +--- + +# Subscribe to a Shared Repo + +## Overview + +Configured guidelines repos are multi-reader / multi-writer git databases, +described in a single unified list in `evolve.config.yaml`: + +```yaml +repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve.git + branch: main + notes: public memory for foobar project + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org +``` + +- `scope: read` — download-only. Synced on every run. +- `scope: write` — publish target. Synced on every run too, so you see + what you have already published and anything others have pushed. + +## Workflow + +### Step 1: Bootstrap config if missing + +If `evolve.config.yaml` does not exist, ask the user for a username and +create: + +```yaml +identity: + user: {username} +repos: [] +sync: + on_session_start: true +``` + +Also ensure `.evolve/` is gitignored: + +```bash +grep -qxF '.evolve/' .gitignore 2>/dev/null || echo '.evolve/' >> .gitignore +``` + +### Step 2: Gather details + +Ask the user for: + +- the remote URL for the guidelines repo +- a short local name such as `alice` +- the scope: `read` (default, subscribe-only) or `write` (also a publish target) +- an optional note + +### Step 3: Run subscribe script + +```bash +{{ invoke("subscribe", "subscribe.py", ['--name "{name}"', '--remote "{remote}"', '--branch main', '--scope "{scope}"', '--notes "{notes}"']) }} +``` + +### Step 4: Confirm + +Tell the user the repo was added and they can run `{{ skill_ref("sync") }}` +immediately if they want to pull updates now. + +## Notes + +- The repo is cloned directly into `.evolve/entities/subscribed/{name}/`, + which doubles as the recall mirror +- Subscribed entities will appear in recall with `[from: {name}]` + annotations +- Read-scope repos use a shallow clone; write-scope repos use a full + clone so publish commits can be rebased and pushed cleanly diff --git a/plugin-source/skills/evolve-lite/subscribe/scripts/subscribe.py b/plugin-source/skills/evolve-lite/subscribe/scripts/subscribe.py new file mode 100755 index 00000000..ef6b0cd0 --- /dev/null +++ b/plugin-source/skills/evolve-lite/subscribe/scripts/subscribe.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Add a repo to the unified ``repos`` list and clone it locally. + +Shared (multi-reader, multi-writer) repos are described in +``evolve.config.yaml``: + + repos: + - name: memory + scope: write + remote: git@github.com:alice/evolve.git + branch: main + notes: public memory for foobar project + - name: org-memory + scope: read + remote: git@github.com:acme/org-memory.git + branch: main + notes: private memory shared only within my org + +``scope: read`` — download-only (pulled by sync). +``scope: write`` — publish target; also pulled by sync so you see what + others push and what you have already published. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import ( # noqa: E402 + VALID_SCOPES, + is_valid_repo_name, + load_config, + normalize_repos, + save_config, + set_repos, +) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--name", required=True, help="Short repo name") + parser.add_argument("--remote", required=True, help="Git remote URL") + parser.add_argument("--branch", default="main", help="Branch to track") + parser.add_argument("--scope", default="read", choices=VALID_SCOPES) + parser.add_argument("--notes", default="") + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + dest = (evolve_dir / "entities" / "subscribed" / args.name).resolve() + + if not is_valid_repo_name(args.name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): + print(f"Error: invalid subscription name: {args.name!r}", file=sys.stderr) + sys.exit(1) + + cfg = load_config(project_root) + repos = normalize_repos(cfg) + + for repo in repos: + if repo.get("name") == args.name: + print(f"Error: subscription '{args.name}' already exists in config.", file=sys.stderr) + sys.exit(1) + + if dest.exists(): + print(f"Error: destination already exists: {dest}", file=sys.stderr) + sys.exit(1) + + dest.parent.mkdir(parents=True, exist_ok=True) + # Write-scope repos need full history so the user can safely rebase and + # push publish commits. Read-scope repos only mirror, so shallow is enough. + clone_cmd = ["git", "clone", args.remote, str(dest), "--branch", args.branch] + if args.scope == "read": + clone_cmd += ["--depth", "1"] + try: + subprocess.run(clone_cmd, check=True, timeout=60, capture_output=True, text=True) + except subprocess.TimeoutExpired: + shutil.rmtree(dest, ignore_errors=True) + print("Error: git clone timed out", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as exc: + shutil.rmtree(dest, ignore_errors=True) + detail = (exc.stderr or exc.stdout or "").strip() or f"exit {exc.returncode}" + print(f"Error: git clone failed: {detail}", file=sys.stderr) + sys.exit(1) + + repos.append( + { + "name": args.name, + "scope": args.scope, + "remote": args.remote, + "branch": args.branch, + "notes": args.notes, + } + ) + set_repos(cfg, repos) + try: + save_config(cfg, project_root) + except Exception: + repos.pop() + if dest.exists(): + shutil.rmtree(dest) + raise + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + try: + audit_append( + project_root=project_root, + action="subscribe", + actor=actor, + name=args.name, + scope=args.scope, + remote=args.remote, + ) + except Exception as exc: + # Audit logging is best-effort: a failed append shouldn't roll back + # an otherwise successful subscribe (the repo is cloned, the config + # has the entry). Warn loudly so the user can fix the audit log + # path without losing the subscription. Originally rolled back on + # main's PR #245 (#244 e2e fix). + print(f"Warning: failed to append audit entry for subscribe: {exc}", file=sys.stderr) + + print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/sync/SKILL.md.j2 b/plugin-source/skills/evolve-lite/sync/SKILL.md.j2 new file mode 100644 index 00000000..fe55ab1b --- /dev/null +++ b/plugin-source/skills/evolve-lite/sync/SKILL.md.j2 @@ -0,0 +1,35 @@ +{%- from "_macros.j2" import invoke, skill_ref with context -%} +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}sync +description: Pull the latest guidelines from every configured repo (read- and write-scope). +--- + +# Sync Repos + +## Overview + +Pull the latest guidelines from every repo in `evolve.config.yaml` +`repos:` list — both `scope: read` (subscribe-only) and `scope: write` +(publish targets). Write-scope repos use a rebase strategy so any +unpushed local publish commits are preserved. + +## Workflow + +### Step 1: Run sync script + +```bash +{{ invoke("sync", "sync.py") }} +``` + +### Step 2: Display summary + +Show the script output to the user. If there are no repos configured, +tell them they can add one with `{{ skill_ref("subscribe") }}`. If there +are no changes, explain that everything is already up to date. + +## Notes + +- Read-scope repos are mirrored exactly via `git fetch` + `git reset --hard` +- Write-scope repos use `git fetch` + `git rebase` so unpushed local + publish commits are preserved +- Sync results are logged to `.evolve/audit.log` diff --git a/plugin-source/skills/evolve-lite/sync/scripts/sync.py b/plugin-source/skills/evolve-lite/sync/scripts/sync.py new file mode 100755 index 00000000..33c34716 --- /dev/null +++ b/plugin-source/skills/evolve-lite/sync/scripts/sync.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Pull the latest guidelines from every configured repo. + +Every repo in ``evolve.config.yaml`` (both read- and write-scope) is cloned +into ``.evolve/entities/subscribed/{name}/`` so recall sees everything through +a single root. Publish commits stay local until pushed, so write-scope repos +use ``git fetch`` + ``git rebase`` (preserves unpushed commits) while +read-scope repos use ``git fetch`` + ``git reset --hard`` (exact mirror). + +Usage: + --quiet Suppress output if no changes. + --config PATH Path to config file (default: evolve.config.yaml at project root). + --session-start Apply the ``sync.on_session_start`` gate (automatic hook runs). +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import classify_repo_entry, load_config # noqa: E402 + + +_GIT_TIMEOUT = 30 # seconds + + +def _git(repo_path, *args, timeout=_GIT_TIMEOUT): + try: + return subprocess.run( + ["git", "-C", str(repo_path), *args], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return None + + +def sync_read_only(repo_path, branch): + """Fetch and hard-reset to origin/{branch} (read-only mirror).""" + fetch = _git(repo_path, "fetch", "origin", branch) + if fetch is None or fetch.returncode != 0: + return fetch + return _git(repo_path, "reset", "--hard", f"origin/{branch}") + + +def sync_writable(repo_path, branch): + """Fetch and rebase local commits onto origin/{branch} (preserves publishes).""" + fetch = _git(repo_path, "fetch", "origin", branch) + if fetch is None or fetch.returncode != 0: + return fetch + rebase = _git(repo_path, "rebase", f"origin/{branch}") + if rebase is None or rebase.returncode != 0: + _git(repo_path, "rebase", "--abort") + return rebase + return rebase + + +def count_delta(repo_path): + result = _git(repo_path, "diff", "--name-status", "HEAD@{1}", "HEAD") + if result is None or result.returncode != 0: + added = len(list(repo_path.glob("**/*.md"))) + return {"added": added, "updated": 0, "removed": 0} + added = updated = removed = 0 + for line in result.stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("\t", 1) + if len(parts) < 2: + continue + status, filename = parts[0].strip(), parts[1].strip() + if not filename.endswith(".md"): + continue + if status.startswith("A"): + added += 1 + elif status.startswith("M"): + updated += 1 + elif status.startswith("D"): + removed += 1 + return {"added": added, "updated": updated, "removed": removed} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--quiet", action="store_true", help="Suppress output if no changes") + parser.add_argument("--config", default=None, help="Explicit config path") + parser.add_argument( + "--session-start", + action="store_true", + help="Apply session-start gating for automatic hook execution", + ) + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + resolved_evolve_dir = evolve_dir.resolve() + project_root = str(resolved_evolve_dir.parent) + audit_root = resolved_evolve_dir if resolved_evolve_dir.name == ".evolve" else resolved_evolve_dir / ".evolve" + + if args.config: + config_path = Path(args.config).resolve() + if config_path.name != "evolve.config.yaml": + print( + f"Error: --config must point to an evolve.config.yaml file, got: {config_path}", + file=sys.stderr, + ) + sys.exit(1) + cfg = load_config(project_root=str(config_path.parent)) + else: + cfg = load_config(project_root) + + sync_cfg = cfg.get("sync", {}) + if args.session_start and isinstance(sync_cfg, dict) and sync_cfg.get("on_session_start") is False: + sys.exit(0) + + raw_entries = cfg.get("repos") if isinstance(cfg, dict) else None + if not isinstance(raw_entries, list): + raw_entries = [] + + repos = [] + rejections = [] + seen = set() + for entry in raw_entries: + repo, rejection = classify_repo_entry(entry) + if rejection is not None: + rejections.append(rejection) + continue + if repo["name"] in seen: + continue + seen.add(repo["name"]) + repos.append(repo) + + if not repos and not rejections: + if not args.quiet: + print("No subscriptions configured. Add one with the evolve-lite:subscribe skill to start syncing shared guidelines.") + sys.exit(0) + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + + summaries = [] + total_delta = {} + any_changes = False + + for rejection in rejections: + raw_name = rejection["raw_name"] + reason = rejection["reason"] + label = repr(raw_name) if raw_name else "" + summaries.append(f"{label} (skipped - {reason})") + + for repo in repos: + name = repo["name"] + scope = repo.get("scope", "read") + branch = repo.get("branch", "main") + remote = repo.get("remote") + + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + repo_path = (evolve_dir / "entities" / "subscribed" / name).resolve() + + if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base): + summaries.append(f"{name!r} (skipped - invalid subscription name)") + continue + + if not repo_path.is_dir(): + if not remote: + summaries.append(f"{name} (not cloned)") + continue + repo_path.parent.mkdir(parents=True, exist_ok=True) + clone_cmd = ["git", "clone", "--branch", branch] + if scope == "read": + clone_cmd += ["--depth", "1"] + clone_cmd += ["--", remote, str(repo_path)] + try: + clone_result = subprocess.run( + clone_cmd, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + ) + except subprocess.TimeoutExpired: + summaries.append(f"{name} (re-clone failed - timeout)") + total_delta[name] = {"added": 0, "updated": 0, "removed": 0} + any_changes = True + continue + if clone_result.returncode != 0: + summaries.append(f"{name} (re-clone failed: {clone_result.stderr.strip()})") + total_delta[name] = {"added": 0, "updated": 0, "removed": 0} + any_changes = True + continue + + if scope == "write": + pull_result = sync_writable(repo_path, branch) + else: + pull_result = sync_read_only(repo_path, branch) + + if pull_result is None: + summaries.append(f"{name} (sync failed - timeout)") + total_delta[name] = {"added": 0, "updated": 0, "removed": 0} + any_changes = True + continue + if pull_result.returncode != 0: + error_lines = (pull_result.stderr or pull_result.stdout or "").strip().splitlines() + short_error = error_lines[-1] if error_lines else f"git exited with {pull_result.returncode}" + summaries.append(f"{name} (sync failed: {short_error})") + total_delta[name] = {"added": 0, "updated": 0, "removed": 0} + any_changes = True + continue + + delta = count_delta(repo_path) + total_delta[name] = delta + if any(value > 0 for value in delta.values()): + any_changes = True + + summaries.append(f"{name} [{scope}] (+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed)") + + audit_append(project_root=str(audit_root.parent), action="sync", actor=actor, delta=total_delta) + + if args.quiet and not any_changes: + sys.exit(0) + + print(f"Synced {len(summaries)} repo(s): " + ", ".join(summaries)) + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/evolve-lite/unsubscribe/SKILL.md.j2 b/plugin-source/skills/evolve-lite/unsubscribe/SKILL.md.j2 new file mode 100644 index 00000000..5f63e63a --- /dev/null +++ b/plugin-source/skills/evolve-lite/unsubscribe/SKILL.md.j2 @@ -0,0 +1,57 @@ +{%- from "_macros.j2" import invoke with context -%} +--- +name: {% if platform == "bob" %}evolve-lite:{% endif %}unsubscribe +description: Remove a repo from the unified repos list and delete its local clone. +--- + +# Remove a Repo + +## Overview + +Remove a configured repo (any scope) from `evolve.config.yaml` and delete +its local clone at `.evolve/entities/subscribed/{name}/`. Warn the user +before removing a write-scope repo since any unpushed local publish +commits will be lost. + +## Workflow + +### Step 1: List repos + +Run: + +```bash +{{ invoke("unsubscribe", "unsubscribe.py", "--list") }} +``` + +Show the repos to the user (including `scope` and `notes`) and ask which +one to remove. + +### Step 2: Confirm + +Confirm deletion of `.evolve/entities/subscribed/{name}/`. If the repo has +`scope: write`, add a warning that unpushed local publish commits will be +lost. + +### Step 3: Run unsubscribe script + +```bash +{{ invoke("unsubscribe", "unsubscribe.py", "--name {name}") }} +``` +{% if platform in ["claude", "claw-code"] %} +For a write-scope repo, the script refuses to remove the local clone +without `--force` so unpushed publishes can't disappear by accident: + +```bash +{{ invoke("unsubscribe", "unsubscribe.py", "--name {name} --force") }} +``` +{% endif %} +### Step 4: Confirm + +Tell the user the repo was removed. + +## Notes + +- This removes the entry from `evolve.config.yaml` `repos:` list +- Deletes `.evolve/entities/subscribed/{name}/` (the local clone, also + the recall mirror) +- The entities will no longer appear in recall diff --git a/plugin-source/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py b/plugin-source/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py new file mode 100755 index 00000000..f0ceeb54 --- /dev/null +++ b/plugin-source/skills/evolve-lite/unsubscribe/scripts/unsubscribe.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Remove a repo from the unified ``repos`` list and delete its local clone.""" + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +# claude/claw-code/codex/bob all ship a sibling lib/ next to skills/; bob's +# installer copies it to .bob/evolve-lib/, hence both names are checked. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"): + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break + if _lib is not None: + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from audit import append as audit_append # noqa: E402 +from config import ( # noqa: E402 + is_valid_repo_name, + load_config, + normalize_repos, + save_config, + set_repos, +) + + +def main(): + parser = argparse.ArgumentParser() + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--list", action="store_true", help="Print repos as JSON array") + group.add_argument("--name", help="Name of repo to remove") + parser.add_argument( + "--force", + action="store_true", + help="Required to remove a write-scope repo (its clone may hold unpushed publishes)", + ) + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent) + + cfg = load_config(project_root) + repos = normalize_repos(cfg) + + if args.list: + print(json.dumps(repos, indent=2)) + return + + name = args.name + subscribed_base = (evolve_dir / "entities" / "subscribed").resolve() + dest = (evolve_dir / "entities" / "subscribed" / name).resolve() + + if not is_valid_repo_name(name) or dest == subscribed_base or not dest.is_relative_to(subscribed_base): + print(f"Error: invalid subscription name: {name!r}", file=sys.stderr) + sys.exit(1) + + matched = next((r for r in repos if r.get("name") == name), None) + if matched is None: + print(f"Error: subscription '{name}' not found.", file=sys.stderr) + sys.exit(1) + + if matched.get("scope") == "write" and not args.force: + print( + f"Error: '{name}' is a write-scope repo. Removing it would delete the local clone, " + "including any unpushed publish commits. Re-run with --force to confirm.", + file=sys.stderr, + ) + sys.exit(1) + + new_repos = [r for r in repos if r.get("name") != name] + + if dest.exists(): + shutil.rmtree(dest) + print(f"Deleted {dest}") + else: + print(f"Warning: {dest} did not exist.", file=sys.stderr) + + set_repos(cfg, new_repos) + save_config(cfg, project_root) + + identity = cfg.get("identity", {}) + actor = identity.get("user", "unknown") if isinstance(identity, dict) else "unknown" + audit_append(project_root=project_root, action="unsubscribe", actor=actor, name=name) + + print(f"Removed subscription '{name}' from config.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 595baab4..b17d28c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,7 @@ explicit_package_bases = true exclude = [ "build/", "platform-integrations/", + "plugin-source/", "examples/", ] diff --git a/tests/platform_integrations/conftest.py b/tests/platform_integrations/conftest.py index 0e5b4910..24551be5 100644 --- a/tests/platform_integrations/conftest.py +++ b/tests/platform_integrations/conftest.py @@ -254,7 +254,7 @@ def assert_all_bob_commands_installed(bob_dir: Path): """Assert every evolve-lite command in the source tree is installed.""" repo_root = Path(__file__).parent.parent.parent commands_src = repo_root / "platform-integrations" / "bob" / "evolve-lite" / "commands" - for cmd_file in sorted(commands_src.glob("evolve-lite:*.md")): + for cmd_file in sorted(commands_src.glob("evolve-lite-*.md")): FileAssertions.assert_file_exists(bob_dir / "commands" / cmd_file.name) @staticmethod @@ -491,7 +491,7 @@ def create_existing_hooks_with_shared_evolve_group(project_dir: Path): "sh -lc '" 'd="$PWD"; ' "while :; do " - 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'candidate="$d/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py"; ' 'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; ' '[ "$d" = "/" ] && break; ' 'd="$(dirname "$d")"; ' @@ -536,7 +536,7 @@ def create_existing_hooks_with_dict_evolve_group(project_dir: Path): "sh -lc '" 'd="$PWD"; ' "while :; do " - 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'candidate="$d/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py"; ' 'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; ' '[ "$d" = "/" ] && break; ' 'd="$(dirname "$d")"; ' diff --git a/tests/platform_integrations/test_bob_sharing.py b/tests/platform_integrations/test_bob_sharing.py index f9108352..e98bdaff 100644 --- a/tests/platform_integrations/test_bob_sharing.py +++ b/tests/platform_integrations/test_bob_sharing.py @@ -10,36 +10,32 @@ import pytest -def _load_claude_config_module(): - path = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite/lib/config.py" - spec = importlib.util.spec_from_file_location("claude_evolve_lite_config", path) +_BOB_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/bob/evolve-lite" + + +def _load_bob_config_module(): + path = _BOB_ROOT / "lib/config.py" + spec = importlib.util.spec_from_file_location("bob_evolve_lite_config", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module -cfg_module = _load_claude_config_module() +cfg_module = _load_bob_config_module() pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] -_BOB_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/bob/evolve-lite" -_CLAUDE_LIB = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite/lib" -SUBSCRIBE_SCRIPT = _BOB_ROOT / "skills/evolve-lite:subscribe/scripts/subscribe.py" -UNSUBSCRIBE_SCRIPT = _BOB_ROOT / "skills/evolve-lite:unsubscribe/scripts/unsubscribe.py" -SYNC_SCRIPT = _BOB_ROOT / "skills/evolve-lite:sync/scripts/sync.py" -PUBLISH_SCRIPT = _BOB_ROOT / "skills/evolve-lite:publish/scripts/publish.py" -SAVE_SCRIPT = _BOB_ROOT / "skills/evolve-lite:learn/scripts/save_entities.py" -RETRIEVE_SCRIPT = _BOB_ROOT / "skills/evolve-lite:recall/scripts/retrieve_entities.py" +SUBSCRIBE_SCRIPT = _BOB_ROOT / "skills/evolve-lite-subscribe/scripts/subscribe.py" +UNSUBSCRIBE_SCRIPT = _BOB_ROOT / "skills/evolve-lite-unsubscribe/scripts/unsubscribe.py" +SYNC_SCRIPT = _BOB_ROOT / "skills/evolve-lite-sync/scripts/sync.py" +PUBLISH_SCRIPT = _BOB_ROOT / "skills/evolve-lite-publish/scripts/publish.py" +SAVE_SCRIPT = _BOB_ROOT / "skills/evolve-lite-learn/scripts/save_entities.py" +RETRIEVE_SCRIPT = _BOB_ROOT / "skills/evolve-lite-recall/scripts/retrieve_entities.py" def run_script(script, project_dir, args=None, evolve_dir=None, stdin_data=None, expect_success=True): - """Run a Bob script with proper environment setup. - - Injects Claude's lib directory into PYTHONPATH so Bob's scripts can import - shared modules (config, audit, entity_io) without requiring a symlink in the repo. - """ + """Run a Bob script with the test project as cwd.""" env = {**os.environ} - env["PYTHONPATH"] = str(_CLAUDE_LIB) + os.pathsep + env.get("PYTHONPATH", "") if evolve_dir: env["EVOLVE_DIR"] = str(evolve_dir) return subprocess.run( @@ -684,7 +680,7 @@ def test_returns_entities_from_private_dir(self, temp_project_dir): result = run_script(RETRIEVE_SCRIPT, temp_project_dir, evolve_dir=evolve_dir) assert "Private tip" in result.stdout - assert "## Entities for this task" in result.stdout + assert "## Evolve entities for this task" in result.stdout def test_returns_published_entities_from_write_clone(self, temp_project_dir): """Published guidelines live in entities/subscribed/{repo}/guideline/.""" diff --git a/tests/platform_integrations/test_build_pipeline.py b/tests/platform_integrations/test_build_pipeline.py new file mode 100644 index 00000000..0ae18c29 --- /dev/null +++ b/tests/platform_integrations/test_build_pipeline.py @@ -0,0 +1,345 @@ +"""Tests for plugin-source/build_plugins.py — the plugin source compilation pipeline. + +The build pipeline has three observable contracts that these tests pin down: + + 1. Render is the inverse of check: rendering plugin-source/ into a tree, then + running check_drift against that tree, returns 0. + 2. Render is hermetic: each platform's plugin_root is wiped before write, so + a stale orphan in platform-integrations// is gone after render. + 3. Render is deterministic: rendering twice into the same tree produces + byte-identical output. + +Plus targeted tests for the routing conventions (`_/` prefix, bob's +1:1 commands generation, the per-platform Jinja context). Where order-sensitive +behavior used to slip in (e.g. "pick the alphabetically-first verbatim entry"), +these tests now address files by name so they don't break when the file tree +shifts. + +Refs #219. +""" + +from __future__ import annotations + +import filecmp +import importlib.util +import shutil +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +BUILD_SCRIPT = REPO_ROOT / "plugin-source" / "build_plugins.py" + + +def _import_build_module(): + spec = importlib.util.spec_from_file_location("_build_plugins_under_test", BUILD_SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def build_module(): + return _import_build_module() + + +@pytest.fixture +def isolated_repo(tmp_path, build_module, monkeypatch): + """Copy plugin-source/ into tmp_path and monkeypatch REPO_ROOT / PLUGIN_SOURCE_DIR + so render_to and check_drift operate against an isolated tree. Returns tmp_path.""" + shutil.copytree(REPO_ROOT / "plugin-source", tmp_path / "plugin-source") + monkeypatch.setattr(build_module, "REPO_ROOT", tmp_path) + monkeypatch.setattr(build_module, "PLUGIN_SOURCE_DIR", tmp_path / "plugin-source") + return tmp_path + + +@pytest.fixture +def rendered_repo(isolated_repo, build_module): + """isolated_repo + a fresh render — for tests that inspect rendered output.""" + build_module.render_to(isolated_repo) + return isolated_repo + + +def _plugin_root(manifest, platform: str) -> Path: + """Absolute path of the platform's plugin_root from the (possibly + monkeypatched) manifest. After `isolated_repo` patches `build_module.REPO_ROOT`, + this already points into the test's tmp_path.""" + return Path(manifest.platforms[platform].plugin_root) + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestManifest: + """Manifest sanity — purely structural, no I/O on platform-integrations/.""" + + def test_manifest_loads_without_error(self, build_module): + manifest = build_module.load_manifest() + assert manifest.platforms, "manifest declares no platforms" + assert manifest.files, "manifest declares no files" + + def test_every_manifest_source_exists(self, build_module): + manifest = build_module.load_manifest() + for entry in manifest.files: + assert entry.source.is_file(), f"manifest references missing source: {entry.source}" + + def test_every_target_platform_is_declared(self, build_module): + manifest = build_module.load_manifest() + declared = set(manifest.platforms) + for entry in manifest.files: + for platform in entry.platforms: + assert platform in declared, f"unknown platform {platform!r} in entry {entry.source}" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestRenderInverseOfCheck: + """The headline invariant: render then check is silent and returns 0.""" + + def test_render_then_check_is_clean(self, isolated_repo, build_module, capsys): + build_module.render_to(isolated_repo) + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 0, f"check_drift returned {rc} on a fresh render. stderr:\n{captured.err}" + assert captured.err == "", f"check_drift emitted output on a fresh render:\n{captured.err}" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestRenderProperties: + def test_render_is_idempotent(self, isolated_repo, build_module): + """Rendering twice into the same tree must produce byte-identical output.""" + build_module.render_to(isolated_repo) + first = {p.relative_to(isolated_repo): p.read_bytes() for p in (isolated_repo / "platform-integrations").rglob("*") if p.is_file()} + build_module.render_to(isolated_repo) + second = {p.relative_to(isolated_repo): p.read_bytes() for p in (isolated_repo / "platform-integrations").rglob("*") if p.is_file()} + assert first.keys() == second.keys(), "second render produced a different file set" + for path, body in first.items(): + assert body == second[path], f"non-deterministic output: {path}" + + def test_render_wipes_orphans_under_each_plugin_root(self, isolated_repo, build_module): + """Stale files under any platform's plugin_root must be removed before write.""" + manifest = build_module.load_manifest() + orphans = [] + for platform in manifest.platforms: + root = _plugin_root(manifest, platform) + root.mkdir(parents=True, exist_ok=True) + orphan = root / "leftover-orphan.txt" + orphan.write_text("stale content") + orphans.append(orphan) + + build_module.render_to(isolated_repo) + + for orphan in orphans: + assert not orphan.exists(), f"render did not wipe orphan {orphan}" + + def test_every_non_excluded_target_is_rendered(self, rendered_repo, build_module): + """For every (file, platform) declared by the manifest that the platform + doesn't exclude, the rendered output exists at the expected path.""" + manifest = build_module.load_manifest() + for entry in manifest.files: + for platform in entry.platforms: + cfg = manifest.platforms[platform] + if cfg.excludes(entry.target_rel): + continue + rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(entry.target_rel) + assert rendered.is_file(), f"render did not emit {rendered}" + + def test_verbatim_files_match_source_byte_for_byte(self, rendered_repo, build_module): + """Non-template files are copied byte-for-byte (excludes-aware).""" + manifest = build_module.load_manifest() + for entry in manifest.files: + if build_module._is_template(entry.source): + continue + for platform in entry.platforms: + cfg = manifest.platforms[platform] + if cfg.excludes(entry.target_rel): + continue + rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(entry.target_rel) + assert filecmp.cmp(entry.source, rendered, shallow=False), f"verbatim mismatch at {rendered}" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestPerPlatformRouting: + """Files under plugin-source/_/ ship only to that platform, and the + `_/` prefix is stripped from the output target.""" + + def test_underscore_platform_files_route_to_only_that_platform(self, build_module): + manifest = build_module.load_manifest() + for src, platforms in build_module._walk_sources(): + rel = src.relative_to(build_module.PLUGIN_SOURCE_DIR) + head = rel.parts[0] + if head.startswith("_") and head[1:] in manifest.platforms: + expected = (head[1:],) + assert platforms == expected, f"{rel} routes to {platforms}, expected {expected}" + + def test_underscore_platform_prefix_stripped_from_output(self, rendered_repo, build_module): + """A file at _/ renders to /, not /_/.""" + manifest = build_module.load_manifest() + for src, platforms in build_module._walk_sources(): + rel = src.relative_to(build_module.PLUGIN_SOURCE_DIR) + head = rel.parts[0] + if not (head.startswith("_") and head[1:] in manifest.platforms): + continue + (platform,) = platforms + target_rel = build_module._target_for(src) + rendered = _plugin_root(manifest, platform) / target_rel + assert rendered.is_file(), f"per-platform source {src} did not render to {rendered}" + # And nothing under a `_/` subpath should appear in the output. + stray = _plugin_root(manifest, platform) / head + assert not stray.exists(), f"render leaked the `_/` prefix into {stray}" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestBobCommandGeneration: + """Bob commands are auto-generated 1:1 from the skills walk; description is + pulled from each skill's SKILL.md.j2 frontmatter and the body uses the + dash-form folder reference (since bob resolves skills by folder name).""" + + def _bob_commands_dir(self, rendered_repo, build_module) -> Path: + manifest = build_module.load_manifest() + return _plugin_root(manifest, "bob") / "commands" + + def test_one_command_per_skill(self, rendered_repo, build_module): + skill_names = sorted(d.name for d in build_module._discover_skills()) + commands = sorted(p.stem.removeprefix("evolve-lite-") for p in self._bob_commands_dir(rendered_repo, build_module).glob("*.md")) + assert commands == skill_names, "bob commands are not 1:1 with skills" + + def test_command_body_references_dash_form(self, rendered_repo, build_module): + for cmd_file in self._bob_commands_dir(rendered_repo, build_module).glob("*.md"): + skill = cmd_file.stem.removeprefix("evolve-lite-") + body = cmd_file.read_text() + assert f"`evolve-lite-{skill}`" in body, f"{cmd_file.name} body should reference the dash-form folder" + assert f"evolve-lite:{skill}" not in body, f"{cmd_file.name} body should not use the colon form (bob resolves by folder)" + + def test_command_description_comes_from_skill_frontmatter(self, rendered_repo, build_module): + for skill_dir in build_module._discover_skills(): + description = build_module._read_skill_description(skill_dir) + cmd_file = self._bob_commands_dir(rendered_repo, build_module) / f"evolve-lite-{skill_dir.name}.md" + assert f"description: {description}\n" in cmd_file.read_text() + + def test_command_frontmatter_has_no_name_field(self, rendered_repo, build_module): + """Bob's command schema only honors `description` / `argument-hints`; + an explicit `name:` would be silently ignored or rejected.""" + for cmd_file in self._bob_commands_dir(rendered_repo, build_module).glob("*.md"): + text = cmd_file.read_text() + # Frontmatter is the block between the first two `---` lines. + _, frontmatter, _ = text.split("---", 2) + assert "\nname:" not in frontmatter, f"{cmd_file.name} has a `name:` field bob doesn't support" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestCheckDrift: + """Drift detection — pin specific failure modes by file name, not by index.""" + + def test_committed_tree_is_clean(self, build_module, capsys): + """The real committed platform-integrations/ matches a fresh render of plugin-source/.""" + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 0, f"check_drift returned {rc}. stderr:\n{captured.err}\nRun `just compile-plugins` and commit the result." + + def test_perturbed_template_is_detected_as_drift(self, rendered_repo, build_module, capsys): + target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md" + assert target.is_file(), "test prerequisite missing — claude learn/SKILL.md not rendered" + target.write_bytes(target.read_bytes() + b"\n# perturbation\n") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "drift:" in captured.err + + def test_perturbed_verbatim_file_is_detected_as_drift(self, rendered_repo, build_module, capsys): + target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/scripts/on_stop.py" + assert target.is_file(), "test prerequisite missing — claude learn/scripts/on_stop.py not rendered" + target.write_bytes(target.read_bytes() + b"\n# perturbation\n") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "drift:" in captured.err + + def test_perturbed_bob_command_is_detected_as_drift(self, rendered_repo, build_module, capsys): + """Bob commands are generated, not source-tracked — their drift is also caught.""" + target = rendered_repo / "platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md" + assert target.is_file(), "test prerequisite missing — bob's evolve-lite-learn command not rendered" + target.write_bytes(target.read_bytes() + b"\n# perturbation\n") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "drift:" in captured.err + + def test_missing_rendered_file_is_detected(self, rendered_repo, build_module, capsys): + target = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/learn/SKILL.md" + assert target.is_file() + target.unlink() + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "missing managed file:" in captured.err + + def test_orphan_file_under_plugin_root_is_detected(self, rendered_repo, build_module, capsys): + """A file with no source path under plugin_root is flagged as an orphan. + + Reproduces visahak's PR #235 finding: dropping `orphan.txt` into + `platform-integrations/claude/plugins/evolve-lite/` and re-running + `check_drift()` previously returned 0 because the check only walked + the *expected* file set and never enumerated the rendered tree + for unexpected extras. + """ + orphan = rendered_repo / "platform-integrations/claude/plugins/evolve-lite/orphan.txt" + orphan.write_text("not generated from plugin-source/\n") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "orphan:" in captured.err + assert "orphan.txt" in captured.err + + def test_orphan_in_nested_subdir_is_detected(self, rendered_repo, build_module, capsys): + """The walk descends into subdirectories — orphans aren't only checked at the root.""" + orphan = rendered_repo / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/learn/leftover.md" + orphan.write_text("stale skill artifact\n") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1 + assert "orphan:" in captured.err + assert "leftover.md" in captured.err + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestJinjaTemplating: + def test_template_renders_with_per_platform_context(self, rendered_repo, build_module): + """A .j2 source rendered for two non-excluded platforms produces platform-specific output.""" + manifest = build_module.load_manifest() + candidate = next( + ( + e + for e in manifest.files + if build_module._is_template(e.source) + and sum(1 for p in e.platforms if not manifest.platforms[p].excludes(e.target_rel)) >= 2 + ), + None, + ) + if candidate is None: + pytest.skip("manifest has no templated source shipped to two non-excluded platforms") + + outputs = [] + for platform in candidate.platforms: + cfg = manifest.platforms[platform] + if cfg.excludes(candidate.target_rel): + continue + rendered = _plugin_root(manifest, platform) / cfg.rewrite_target(candidate.target_rel) + outputs.append(rendered.read_bytes()) + + assert any(a != b for a, b in zip(outputs, outputs[1:])), ( + "every platform produced the same bytes for a templated source — the .j2 file is not actually using its per-platform context" + ) diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py index 1284dddf..1bbc6d8d 100644 --- a/tests/platform_integrations/test_codex.py +++ b/tests/platform_integrations/test_codex.py @@ -8,8 +8,8 @@ EVOLVE_PLUGIN = "evolve-lite" -EVOLVE_HOOK_SNIPPET = "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" -EVOLVE_SYNC_SNIPPET = "plugins/evolve-lite/skills/sync/scripts/sync.py" +EVOLVE_HOOK_SNIPPET = "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" +EVOLVE_SYNC_SNIPPET = "plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py" def _marketplace_has_evolve_plugin(path): @@ -59,18 +59,18 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions.assert_dir_exists(plugin_dir) file_assertions.assert_file_exists(plugin_dir / ".codex-plugin" / "plugin.json") file_assertions.assert_file_exists(plugin_dir / "README.md") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "recall") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "publish") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "subscribe") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "unsubscribe") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "sync") - file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "scripts" / "save_entities.py") - file_assertions.assert_file_exists(plugin_dir / "skills" / "recall" / "scripts" / "retrieve_entities.py") - file_assertions.assert_file_exists(plugin_dir / "skills" / "publish" / "scripts" / "publish.py") - file_assertions.assert_file_exists(plugin_dir / "skills" / "subscribe" / "scripts" / "subscribe.py") - file_assertions.assert_file_exists(plugin_dir / "skills" / "unsubscribe" / "scripts" / "unsubscribe.py") - file_assertions.assert_file_exists(plugin_dir / "skills" / "sync" / "scripts" / "sync.py") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "learn") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "recall") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "publish") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "subscribe") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "unsubscribe") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "sync") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "learn" / "scripts" / "save_entities.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "recall" / "scripts" / "retrieve_entities.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "publish" / "scripts" / "publish.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "subscribe" / "scripts" / "subscribe.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "unsubscribe" / "scripts" / "unsubscribe.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "sync" / "scripts" / "sync.py") file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") marketplace_path = temp_project_dir / ".agents" / "plugins" / "marketplace.json" @@ -94,7 +94,7 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, "sh -lc '" 'd="$PWD"; ' "while :; do " - 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'candidate="$d/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py"; ' 'if [ -f "$candidate" ]; then EVOLVE_DIR="$d/.evolve" exec python3 "$candidate"; fi; ' '[ "$d" = "/" ] && break; ' 'd="$(dirname "$d")"; ' @@ -113,7 +113,7 @@ def test_install_creates_expected_files(self, temp_project_dir, install_runner, "sh -lc '" 'd="$PWD"; ' "while :; do " - 'candidate="$d/plugins/evolve-lite/skills/sync/scripts/sync.py"; ' + 'candidate="$d/plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py"; ' 'if [ -f "$candidate" ]; then EVOLVE_DIR="$d/.evolve" exec python3 "$candidate" --quiet --session-start; fi; ' '[ "$d" = "/" ] && break; ' 'd="$(dirname "$d")"; ' diff --git a/tests/platform_integrations/test_codex_sharing.py b/tests/platform_integrations/test_codex_sharing.py index cca75053..c67b10ca 100644 --- a/tests/platform_integrations/test_codex_sharing.py +++ b/tests/platform_integrations/test_codex_sharing.py @@ -11,12 +11,12 @@ pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] _PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/codex/plugins/evolve-lite" -SAVE_SCRIPT = _PLUGIN_ROOT / "skills/learn/scripts/save_entities.py" -RETRIEVE_SCRIPT = _PLUGIN_ROOT / "skills/recall/scripts/retrieve_entities.py" -PUBLISH_SCRIPT = _PLUGIN_ROOT / "skills/publish/scripts/publish.py" -SUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" -UNSUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/unsubscribe/scripts/unsubscribe.py" -SYNC_SCRIPT = _PLUGIN_ROOT / "skills/sync/scripts/sync.py" +SAVE_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/learn/scripts/save_entities.py" +RETRIEVE_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/recall/scripts/retrieve_entities.py" +PUBLISH_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/publish/scripts/publish.py" +SUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/subscribe/scripts/subscribe.py" +UNSUBSCRIBE_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/unsubscribe/scripts/unsubscribe.py" +SYNC_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/sync/scripts/sync.py" HOOK_INPUT = json.dumps({"prompt": "How do I write clean code?"}) diff --git a/tests/platform_integrations/test_config.py b/tests/platform_integrations/test_config.py index 7670224f..f3025b32 100644 --- a/tests/platform_integrations/test_config.py +++ b/tests/platform_integrations/test_config.py @@ -185,7 +185,7 @@ def test_modern_config_pass_through(self): assert repos[0]["scope"] == "write" assert repos[0]["notes"] == "shared" - def test_invalid_scope_entries_dropped(self, capsys): + def test_invalid_scope_entries_dropped(self): cfg = { "repos": [ {"name": "x", "scope": "weird", "remote": "git@x:y/z.git"}, @@ -194,7 +194,11 @@ def test_invalid_scope_entries_dropped(self, capsys): } repos = cfg_module.normalize_repos(cfg) assert [r["name"] for r in repos] == ["y"] - assert "unknown scope" in capsys.readouterr().err + # classify_repo_entry surfaces the rejection reason for callers that + # want to report it; normalize_repos drops it silently. + repo, rejection = cfg_module.classify_repo_entry(cfg["repos"][0]) + assert repo is None + assert rejection["reason"] == "unknown scope" def test_scope_whitespace_tolerated(self): cfg = {"repos": [{"name": "x", "scope": " write ", "remote": "git@x:y/z.git"}]} diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index d99f1d04..874c707d 100644 --- a/tests/platform_integrations/test_idempotency.py +++ b/tests/platform_integrations/test_idempotency.py @@ -66,7 +66,7 @@ def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, # Manually delete one skill import shutil - shutil.rmtree(bob_dir / "skills" / "evolve-lite:learn") + shutil.rmtree(bob_dir / "skills" / "evolve-lite-learn") # Reinstall install_runner.run("install", platform="bob") @@ -76,6 +76,70 @@ def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, file_assertions.assert_file_exists(bob_dir / "custom_modes.yaml") +@pytest.mark.platform_integrations +class TestBobLegacyMigration: + """Upgrade path from the pre-rename `evolve-lite:` colon-form layout.""" + + def _seed_legacy_artifacts(self, bob_dir): + """Drop a stale colon-form skill + command into .bob/, the way an old install left it.""" + legacy_skill = bob_dir / "skills" / "evolve-lite:learn" + legacy_skill.mkdir(parents=True) + (legacy_skill / "SKILL.md").write_text("legacy colon-form skill\n") + legacy_cmd = bob_dir / "commands" / "evolve-lite:learn.md" + legacy_cmd.parent.mkdir(parents=True, exist_ok=True) + legacy_cmd.write_text("legacy colon-form command\n") + return legacy_skill, legacy_cmd + + def test_install_purges_legacy_colon_form(self, temp_project_dir, install_runner, file_assertions): + """Re-running install over a pre-rename layout wipes the legacy artifacts. + + Reproduces visahak's PR #235 finding: without the purge, `.bob/skills/` + ends up with both `evolve-lite:learn` and `evolve-lite-learn` after + upgrade. + """ + bob_dir = temp_project_dir / ".bob" + legacy_skill, legacy_cmd = self._seed_legacy_artifacts(bob_dir) + + install_runner.run("install", platform="bob", mode="lite") + + # Legacy artifacts gone + assert not legacy_skill.exists(), "legacy colon-form skill survived install" + assert not legacy_cmd.exists(), "legacy colon-form command survived install" + # Current dash-form layout in place + file_assertions.assert_dir_exists(bob_dir / "skills" / "evolve-lite-learn") + file_assertions.assert_file_exists(bob_dir / "commands" / "evolve-lite-learn.md") + + def test_uninstall_purges_legacy_colon_form(self, temp_project_dir, install_runner, file_assertions): + """Uninstall removes legacy colon-form stragglers alongside the dash-form.""" + install_runner.run("install", platform="bob", mode="lite") + bob_dir = temp_project_dir / ".bob" + + # Inject legacy artifacts post-install — simulates an upgrade gap where + # a user moved through several versions and accumulated both forms. + legacy_skill, legacy_cmd = self._seed_legacy_artifacts(bob_dir) + + install_runner.run("uninstall", platform="bob") + + assert not legacy_skill.exists(), "uninstall left legacy colon-form skill behind" + assert not legacy_cmd.exists(), "uninstall left legacy colon-form command behind" + file_assertions.assert_dir_not_exists(bob_dir / "skills" / "evolve-lite-learn") + file_assertions.assert_file_not_exists(bob_dir / "commands" / "evolve-lite-learn.md") + + def test_install_preserves_user_content_during_legacy_purge(self, temp_project_dir, install_runner, bob_fixtures, file_assertions): + """The legacy purge MUST NOT clobber non-evolve user skills/commands.""" + bob_dir = temp_project_dir / ".bob" + custom_skill = bob_fixtures.create_existing_skill(temp_project_dir) + custom_command = bob_fixtures.create_existing_command(temp_project_dir) + legacy_skill, _ = self._seed_legacy_artifacts(bob_dir) + + install_runner.run("install", platform="bob", mode="lite") + + # Legacy purged, user content intact. + assert not legacy_skill.exists() + file_assertions.assert_dir_exists(custom_skill) + file_assertions.assert_file_exists(custom_command) + + @pytest.mark.platform_integrations class TestCodexIdempotency: """Test that Codex installation is idempotent.""" @@ -105,7 +169,7 @@ def test_multiple_installs(self, temp_project_dir, install_runner, file_assertio group for group in prompt_hooks if any( - "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" in hook.get("command", "") for hook in group.get("hooks", []) ) ] @@ -120,12 +184,12 @@ def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, import shutil - shutil.rmtree(plugin_dir / "skills" / "learn") + shutil.rmtree(plugin_dir / "skills" / "evolve-lite" / "learn") install_runner.run("install", platform="codex") - file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") - file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "SKILL.md") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "evolve-lite" / "learn") + file_assertions.assert_file_exists(plugin_dir / "skills" / "evolve-lite" / "learn" / "SKILL.md") file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") @@ -143,13 +207,13 @@ def test_bob_uninstall_install_cycle(self, temp_project_dir, install_runner, bob install_runner.run("install", platform="bob") bob_dir = temp_project_dir / ".bob" - file_assertions.assert_dir_exists(bob_dir / "skills" / "evolve-lite:learn") + file_assertions.assert_dir_exists(bob_dir / "skills" / "evolve-lite-learn") # Uninstall install_runner.run("uninstall", platform="bob") - file_assertions.assert_dir_not_exists(bob_dir / "skills" / "evolve-lite:learn") - file_assertions.assert_dir_not_exists(bob_dir / "skills" / "evolve-lite:recall") + file_assertions.assert_dir_not_exists(bob_dir / "skills" / "evolve-lite-learn") + file_assertions.assert_dir_not_exists(bob_dir / "skills" / "evolve-lite-recall") # Reinstall install_runner.run("install", platform="bob") @@ -189,7 +253,7 @@ def test_codex_uninstall_install_cycle(self, temp_project_dir, install_runner, c hook for group in prompt_hooks for hook in group.get("hooks", []) - if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + if "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" in hook.get("command", "") ] assert not evolve_hooks, "Evolve hook still present after uninstall" @@ -209,7 +273,7 @@ def test_codex_uninstall_install_cycle(self, temp_project_dir, install_runner, c for hook in group.get("hooks", []) ) assert any( - "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" in hook.get("command", "") for group in reinstalled_hooks["hooks"]["UserPromptSubmit"] for hook in group.get("hooks", []) ) diff --git a/tests/platform_integrations/test_plugin_structure.py b/tests/platform_integrations/test_plugin_structure.py index 99fc7302..495e8ca7 100644 --- a/tests/platform_integrations/test_plugin_structure.py +++ b/tests/platform_integrations/test_plugin_structure.py @@ -63,12 +63,12 @@ class TestSkillScripts: @pytest.mark.parametrize( "script_rel", [ - "skills/publish/scripts/publish.py", - "skills/subscribe/scripts/subscribe.py", - "skills/unsubscribe/scripts/unsubscribe.py", - "skills/sync/scripts/sync.py", - "skills/recall/scripts/retrieve_entities.py", - "skills/learn/scripts/save_entities.py", + "skills/evolve-lite/publish/scripts/publish.py", + "skills/evolve-lite/subscribe/scripts/subscribe.py", + "skills/evolve-lite/unsubscribe/scripts/unsubscribe.py", + "skills/evolve-lite/sync/scripts/sync.py", + "skills/evolve-lite/recall/scripts/retrieve_entities.py", + "skills/evolve-lite/learn/scripts/save_entities.py", ], ) def test_script_exists(self, script_rel): diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index 6296d6da..cbf3092b 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -207,7 +207,7 @@ def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, insta for group in session_start_hooks ), "User's SessionStart hook was removed!" assert any( - any("plugins/evolve-lite/skills/sync/scripts/sync.py" in hook.get("command", "") for hook in group.get("hooks", [])) + any("plugins/evolve-lite/skills/evolve-lite/sync/scripts/sync.py" in hook.get("command", "") for hook in group.get("hooks", [])) for group in session_start_hooks ), "Evolve SessionStart hook was not added!" @@ -224,7 +224,7 @@ def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, insta group for group in prompt_hooks if any( - "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + "plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" in hook.get("command", "") for hook in group.get("hooks", []) ) ] @@ -276,5 +276,5 @@ def test_install_all_platforms_preserves_everything( ) # Assert: Evolve content is added everywhere - file_assertions.assert_dir_exists(temp_project_dir / ".bob" / "skills" / "evolve-lite:learn") + file_assertions.assert_dir_exists(temp_project_dir / ".bob" / "skills" / "evolve-lite-learn") file_assertions.assert_dir_exists(temp_project_dir / "plugins" / "evolve-lite") diff --git a/tests/platform_integrations/test_publish.py b/tests/platform_integrations/test_publish.py index 4cdfc5b9..871dabeb 100644 --- a/tests/platform_integrations/test_publish.py +++ b/tests/platform_integrations/test_publish.py @@ -1,4 +1,4 @@ -"""Tests for skills/publish/scripts/publish.py.""" +"""Tests for skills/evolve-lite/publish/scripts/publish.py.""" import json import os @@ -11,8 +11,8 @@ pytestmark = pytest.mark.platform_integrations _REPO_ROOT = Path(__file__).parent.parent.parent -CLAUDE_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/publish/scripts/publish.py" -CODEX_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py" +CLAUDE_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py" +CODEX_PUBLISH_SCRIPT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/publish/scripts/publish.py" PUBLISH_SCRIPT = CLAUDE_PUBLISH_SCRIPT PUBLISH_SCRIPT_VARIANTS = [ ("claude", CLAUDE_PUBLISH_SCRIPT), diff --git a/tests/platform_integrations/test_retrieve.py b/tests/platform_integrations/test_retrieve.py index 56f5ce3d..fb7cf32f 100644 --- a/tests/platform_integrations/test_retrieve.py +++ b/tests/platform_integrations/test_retrieve.py @@ -1,4 +1,4 @@ -"""Tests for skills/recall/scripts/retrieve_entities.py.""" +"""Tests for skills/evolve-lite/recall/scripts/retrieve_entities.py.""" import json import os @@ -11,10 +11,14 @@ pytestmark = pytest.mark.platform_integrations _REPO_ROOT = Path(__file__).parent.parent.parent -CLAUDE_RETRIEVE_SCRIPT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" -CODEX_RETRIEVE_SCRIPT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +CLAUDE_RETRIEVE_SCRIPT = ( + _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" +) +CODEX_RETRIEVE_SCRIPT = ( + _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite/skills/evolve-lite/recall/scripts/retrieve_entities.py" +) SCRIPT_VARIANTS = [ - ("claude", CLAUDE_RETRIEVE_SCRIPT, "Entities for this task"), + ("claude", CLAUDE_RETRIEVE_SCRIPT, "Evolve entities for this task"), ("codex", CODEX_RETRIEVE_SCRIPT, "Evolve entities for this task"), ] diff --git a/tests/platform_integrations/test_save_entities.py b/tests/platform_integrations/test_save_entities.py index 5b00113c..c3114dd1 100644 --- a/tests/platform_integrations/test_save_entities.py +++ b/tests/platform_integrations/test_save_entities.py @@ -1,4 +1,4 @@ -"""Tests for the Claude plugin's skills/learn/scripts/save_entities.py.""" +"""Tests for the Claude plugin's skills/evolve-lite/learn/scripts/save_entities.py.""" import json import os @@ -11,7 +11,7 @@ pytestmark = [pytest.mark.platform_integrations, pytest.mark.e2e] _PLUGIN_ROOT = Path(__file__).parent.parent.parent / "platform-integrations/claude/plugins/evolve-lite" -SAVE_SCRIPT = _PLUGIN_ROOT / "skills/learn/scripts/save_entities.py" +SAVE_SCRIPT = _PLUGIN_ROOT / "skills/evolve-lite/learn/scripts/save_entities.py" def run_save(project_dir, entities, args=None, evolve_dir=None, expect_success=True): diff --git a/tests/platform_integrations/test_skill_directory_names.py b/tests/platform_integrations/test_skill_directory_names.py index 06256eca..b6ff6c0d 100644 --- a/tests/platform_integrations/test_skill_directory_names.py +++ b/tests/platform_integrations/test_skill_directory_names.py @@ -18,12 +18,12 @@ def test_bob_lite_skill_directories_exist(self, platform_integrations_dir): # These are the skills that install.sh tries to copy expected_skills = [ - "evolve-lite:learn", - "evolve-lite:recall", - "evolve-lite:publish", - "evolve-lite:subscribe", - "evolve-lite:unsubscribe", - "evolve-lite:sync", + "evolve-lite-learn", + "evolve-lite-recall", + "evolve-lite-publish", + "evolve-lite-subscribe", + "evolve-lite-unsubscribe", + "evolve-lite-sync", ] for skill_name in expected_skills: @@ -39,7 +39,7 @@ def test_bob_lite_skill_directories_exist(self, platform_integrations_dir): assert skill_md.is_file(), f"SKILL.md not found in {skill_dir}\nEvery skill must have a SKILL.md file." def test_bob_lite_skills_follow_naming_convention(self, platform_integrations_dir): - """Verify that Bob lite skills follow the 'evolve-lite:*' naming convention.""" + """Verify that Bob lite skills follow the 'evolve-lite-*' naming convention.""" bob_lite_skills = platform_integrations_dir / "bob" / "evolve-lite" / "skills" if not bob_lite_skills.exists(): @@ -51,12 +51,12 @@ def test_bob_lite_skills_follow_naming_convention(self, platform_integrations_di skill_name = skill_dir.name - # All evolve skills should start with "evolve-lite:" - assert skill_name.startswith("evolve-lite:"), ( + # All evolve skills should start with "evolve-lite-" + assert skill_name.startswith("evolve-lite-"), ( f"Skill directory '{skill_name}' doesn't follow naming convention.\n" - f"Expected: 'evolve-lite:'\n" + f"Expected: 'evolve-lite-'\n" f"Got: '{skill_name}'\n" - f"This will cause installation failures because install.sh expects the 'evolve-lite:' prefix." + f"This will cause installation failures because install.sh expects the 'evolve-lite-' prefix." ) def test_bob_lite_command_files_exist(self, platform_integrations_dir): @@ -68,7 +68,7 @@ def test_bob_lite_command_files_exist(self, platform_integrations_dir): if not bob_lite_skills.exists(): pytest.skip("Bob lite skills directory doesn't exist") - skill_names = [d.name for d in bob_lite_skills.iterdir() if d.is_dir() and d.name.startswith("evolve-lite:")] + skill_names = [d.name for d in bob_lite_skills.iterdir() if d.is_dir() and d.name.startswith("evolve-lite-")] # Verify each skill has a corresponding command file for skill_name in skill_names: @@ -99,12 +99,12 @@ def test_bob_lite_installation_succeeds(self, temp_project_dir, install_runner, # Verify all expected skills were installed bob_dir = temp_project_dir / ".bob" expected_skills = [ - "evolve-lite:learn", - "evolve-lite:recall", - "evolve-lite:publish", - "evolve-lite:subscribe", - "evolve-lite:unsubscribe", - "evolve-lite:sync", + "evolve-lite-learn", + "evolve-lite-recall", + "evolve-lite-publish", + "evolve-lite-subscribe", + "evolve-lite-unsubscribe", + "evolve-lite-sync", ] for skill_name in expected_skills: diff --git a/tests/platform_integrations/test_subscribe.py b/tests/platform_integrations/test_subscribe.py index 8c8d844d..b7fcc557 100644 --- a/tests/platform_integrations/test_subscribe.py +++ b/tests/platform_integrations/test_subscribe.py @@ -27,11 +27,11 @@ def _load_claude_config_module(): _REPO_ROOT = Path(__file__).parent.parent.parent CLAUDE_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite" CODEX_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite" -SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" -UNSUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/unsubscribe/scripts/unsubscribe.py" +SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/subscribe/scripts/subscribe.py" +UNSUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/unsubscribe/scripts/unsubscribe.py" SUBSCRIBE_SCRIPT_VARIANTS = [ - ("claude", CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py"), - ("codex", CODEX_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py"), + ("claude", CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/subscribe/scripts/subscribe.py"), + ("codex", CODEX_PLUGIN_ROOT / "skills/evolve-lite/subscribe/scripts/subscribe.py"), ] @@ -212,7 +212,7 @@ def test_rolls_back_clone_if_config_write_fails(self, temp_project_dir, local_re finally: cfg_path.chmod(0o644) assert result.returncode != 0 - assert "failed to record subscription" in result.stderr + assert result.stderr.strip(), "expected an error message on stderr" dest = evolve_dir / "entities" / "subscribed" / "alice" assert not dest.exists(), "Clone should be rolled back when config write fails" diff --git a/tests/platform_integrations/test_sync.py b/tests/platform_integrations/test_sync.py index 00f4d754..f2a1cb15 100644 --- a/tests/platform_integrations/test_sync.py +++ b/tests/platform_integrations/test_sync.py @@ -1,4 +1,4 @@ -"""Tests for skills/sync/scripts/sync.py.""" +"""Tests for skills/evolve-lite/sync/scripts/sync.py.""" import json import os @@ -13,12 +13,12 @@ _REPO_ROOT = Path(__file__).parent.parent.parent CLAUDE_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite" CODEX_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/codex/plugins/evolve-lite" -SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/subscribe/scripts/subscribe.py" -SYNC_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/sync/scripts/sync.py" -RETRIEVE_SCRIPT = CODEX_PLUGIN_ROOT / "skills/recall/scripts/retrieve_entities.py" +SUBSCRIBE_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/subscribe/scripts/subscribe.py" +SYNC_SCRIPT = CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/sync/scripts/sync.py" +RETRIEVE_SCRIPT = CODEX_PLUGIN_ROOT / "skills/evolve-lite/recall/scripts/retrieve_entities.py" SYNC_SCRIPT_VARIANTS = [ - ("claude", CLAUDE_PLUGIN_ROOT / "skills/sync/scripts/sync.py"), - ("codex", CODEX_PLUGIN_ROOT / "skills/sync/scripts/sync.py"), + ("claude", CLAUDE_PLUGIN_ROOT / "skills/evolve-lite/sync/scripts/sync.py"), + ("codex", CODEX_PLUGIN_ROOT / "skills/evolve-lite/sync/scripts/sync.py"), ] diff --git a/tests/smoke_skills.py b/tests/smoke_skills.py new file mode 100644 index 00000000..2ce9fd6c --- /dev/null +++ b/tests/smoke_skills.py @@ -0,0 +1,1426 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for evolve-lite skills against real host CLIs. + +Exercises /evolve-lite:learn, /evolve-lite:recall, and /evolve-lite:publish +through the real claude, codex, and bob CLIs in a single throwaway +workspace installed via the project's own `platform-integrations/install.sh` +in `--platform all --mode lite` mode — exactly the way a real user installs. +This drives actual model invocations: expect time and API cost on each run. + +Filename intentionally does not start with `test_` so pytest will not +collect it. Run directly: + + python3 tests/smoke_skills.py # all three platforms + python3 tests/smoke_skills.py --platform codex # one platform + python3 tests/smoke_skills.py --keep # leave tempdir on exit + +Assumptions (per the task brief): + - claude, codex, bob CLIs are installed and already authenticated for + the invoking user. The script does not configure auth. + - For claude and codex, learn is exercised against a *real* seed + task and the pass criterion is "entity count grew above baseline", + not just "exit 0". The chain mechanism differs: + * claude — Stop hooks (save-trajectory + learn) auto-fire after + the seed; learn runs in a forked sub-agent and reads the saved + trajectory off disk. + * codex — no Stop hooks for this; instead the seed prompt + is suffixed with "When done, run ." so the + same session invokes learn at the end. Learn runs in main + context on codex (build_plugins.py only sets `forked_context` + for claude), so it sees the live conversation directly without + needing a saved trajectory. + * bob — skill execution is currently DISABLED. Bob has no way to + run slash commands non-interactively (one-shot prompts can't + drive a `/skill` invocation reliably), so the seed → learn → + recall → publish chain can't be exercised headlessly. We verify + bob's install presence only — the bob section reads + "install-only" and PASSes so long as the SKILL.md files landed + in the workspace. + +Side-effects to be aware of: + - codex and bob installs are project-local under the workspace (per + install.sh's design — codex writes plugins/, .agents/, .codex/; + bob writes .bob/), so the tempdir wipe is the only cleanup needed. + - claude is the odd one out: install.sh's claude path uses CLI install + (`claude plugin marketplace add` + `claude plugin install`) which + mutates the user's real ~/.claude/ and would clobber any + pre-existing evolve-lite install they have. We side-step that by + using install.sh's own documented manual fallback (`claude + --plugin-dir `), so claude never touches global + state during the smoke run. The user's day-to-day claude install is + untouched. + +Out of scope: claw-code; CI; release gating. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import textwrap +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLATFORM_INTEGRATIONS = REPO_ROOT / "platform-integrations" +INSTALL_SH = PLATFORM_INTEGRATIONS / "install.sh" +CLAUDE_PLUGIN_DIR = PLATFORM_INTEGRATIONS / "claude" / "plugins" / "evolve-lite" + +PLATFORMS = ("claude", "codex", "bob") + +# Skill timing ceiling. The real LLM round-trip can be slow; if a single +# invocation exceeds this, we treat the platform as failed and move on. +PER_INVOCATION_TIMEOUT_SECONDS = 300 + + +# ─── logging ────────────────────────────────────────────────────────────────── +# +# Two output modes, auto-selected based on whether stdout is a TTY: +# +# * Live mode (TTY, multi-platform): LiveGroupedHandler keeps a buffer of +# records per thread name (= platform) and redraws the whole region with +# ANSI cursor control on every new record. Lines stay grouped by platform +# and chronologically ordered within a group, so a human sees structured, +# real-time progress for all three platforms at once. +# +# * Line mode (non-TTY: piped, captured by an agent, redirected to a file): +# plain stdlib StreamHandler with `[%(threadName)-7s] %(message)s` format. +# Each line is independently greppable, no escape codes pollute captured +# output. This is the mode that runs when an automation/CI/agent pipes +# this script's stdout. +# +# A `--no-live` flag forces line mode in a TTY for debugging. + +logger = logging.getLogger("smoke") + + +class LiveGroupedHandler(logging.Handler): + """Redraws log records grouped per-platform on every new record. + + Each thread (= platform) gets a section under a `── claude ──` header + with its lines in arrival order. On `emit`, the entire managed region + is rewritten via ANSI cursor controls so old lines don't shift on + top of new ones. Calls are serialized through a lock so concurrent + threads can't race on cursor positioning. + + Records emitted from the orchestrator (MainThread) are dropped in + live mode — the orchestrator's tempdir/targets/cleanup messages + re-rendered the entire region on every line and were the source of + the visible flicker (stacked `── MainThread ──` headers). + + Long lines are allowed to wrap. The cursor-up wipe (`\\033[nF`) is + sized in *physical* terminal rows, not logical lines, so the wipe + still lands on the start of the live region even when individual + lines wrap onto multiple rows. + + Once `finalize()` is called, the handler stops redrawing; further + records print as plain prefixed lines below the final region. + """ + + # Defensive cap: if a single platform produces more than this many + # lines, drop the oldest (with a `…(N earlier lines elided)` notice). + MAX_LINES_PER_GROUP = 16 + + def __init__(self, group_order: tuple[str, ...]) -> None: + super().__init__() + self._order: list[str] = list(group_order) + self._groups: dict[str, list[str]] = {} + self._dropped: dict[str, int] = {} + self._lock = threading.Lock() + self._last_lines = 0 + self._finalized = False + + def _physical_rows(self, line: str) -> int: + """How many terminal rows `line` will occupy after wrapping. + + Read width on every call so window resizes between renders are + picked up — caching at __init__ time would silently desync the + wipe math from the actual screen. + """ + width = max(1, shutil.get_terminal_size((100, 24)).columns) + return max(1, (len(line) + width - 1) // width) + + def emit(self, record: logging.LogRecord) -> None: # pragma: no cover (TTY) + try: + group = record.threadName or "main" + # Drop orchestrator messages in live mode — see class docstring. + # Non-live (line-prefix) mode still prints them via the stdlib + # StreamHandler. + if group == "MainThread": + return + line = self.format(record).replace("\n", " | ") + with self._lock: + if self._finalized: + sys.stdout.write(f"[{group}] {line}\n") + sys.stdout.flush() + return + if group not in self._groups: + self._groups[group] = [] + self._dropped[group] = 0 + if group not in self._order: + self._order.append(group) + self._groups[group].append(line) + excess = len(self._groups[group]) - self.MAX_LINES_PER_GROUP + if excess > 0: + self._groups[group] = self._groups[group][excess:] + self._dropped[group] += excess + self._render() + except Exception: + self.handleError(record) + + def _render(self) -> None: + out: list[str] = [] + if self._last_lines: + # Move cursor to column 1 of the row `_last_lines` rows up, + # then erase from cursor to end of screen. `_last_lines` is a + # *physical-row* count (wrap-aware), so the wipe lands on the + # start of the previously drawn region even when content + # lines wrapped onto multiple rows. + out.append(f"\033[{self._last_lines}F\033[0J") + new_rows = 0 + for group in self._order: + lines = self._groups.get(group) + if not lines: + continue + header = f"── {group} ──" + out.append(header + "\n") + new_rows += self._physical_rows(header) + if self._dropped.get(group): + elided = f" …({self._dropped[group]} earlier lines elided)" + out.append(elided + "\n") + new_rows += self._physical_rows(elided) + for line in lines: + out.append(line + "\n") + new_rows += self._physical_rows(line) + sys.stdout.write("".join(out)) + sys.stdout.flush() + self._last_lines = new_rows + + def finalize(self) -> None: + """Stop redrawing; future records print as plain prefixed lines.""" + with self._lock: + if self._finalized: + return + self._finalized = True + # Newline so the next print() (e.g. summary) starts cleanly + # below the last drawn region instead of overwriting it. + sys.stdout.write("\n") + sys.stdout.flush() + + +def setup_logging(verbose: bool, live: bool, group_order: tuple[str, ...]) -> logging.Handler: + if live: + handler: logging.Handler = LiveGroupedHandler(group_order) + # Live mode renders the threadName as a section header, so the + # message itself doesn't need to repeat it. + handler.setFormatter(logging.Formatter("%(message)s")) + else: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("[%(threadName)-7s] %(message)s")) + logger.handlers = [handler] + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + logger.propagate = False + return handler + + +# ─── per-platform result record ─────────────────────────────────────────────── + + +@dataclass +class SkillResult: + name: str + ok: bool + detail: str = "" + + +@dataclass +class PlatformResult: + platform: str + skipped_reason: str | None = None + setup_error: str | None = None + skills: list[SkillResult] = field(default_factory=list) + log_path: Path | None = None + + @property + def overall_ok(self) -> bool: + if self.skipped_reason or self.setup_error: + return False + return all(s.ok for s in self.skills) + + +def log_status(mark: str, name: str, detail: str) -> None: + """Emit a summary-style status line into the current platform's section. + + Format mirrors the old post-run summary so the live view IS the summary. + `name:8s` matches the original summary's column width — long names + ("install-only", "cache-integrity") overflow it, same as before. + """ + logger.info(f"{mark} {name:8s} {detail}") + + +def record_skill(result: PlatformResult, name: str, ok: bool, detail: str) -> None: + """Append a SkillResult and log it as a `✓/✗ name detail` line. + + Doing both in one call keeps the live section line-for-line consistent + with `result.skills` — every appended SkillResult shows up in the + section as it lands. + """ + result.skills.append(SkillResult(name=name, ok=ok, detail=detail)) + log_status("✓" if ok else "✗", name, detail) + + +# ─── shared tempdir + cleanup ───────────────────────────────────────────────── + + +def make_root_tempdir() -> Path: + root = Path(tempfile.mkdtemp(prefix="evolve-smoke-")) + logger.info(f"tempdir: {root}") + return root + + +def setup_isolated_codex_home(workspace: Path) -> Path: + """Create a tempdir-scoped CODEX_HOME and copy the user's auth. + + Codex caches plugins under $CODEX_HOME/plugins/cache/. Pointing + CODEX_HOME at a workspace-local path keeps every byte of plugin + state — cache, memories, logs, config — under the tempdir so it + gets wiped with the smoke run, no user-home pollution. + + auth.json + installation_id are copied from ~/.codex so the run + stays authenticated. config.toml is NOT inherited from the user's + home — register_codex_plugin() writes a fresh one with the entries + needed for `$evolve-lite:` to resolve. + """ + codex_home = workspace / ".codex-home" + codex_home.mkdir(parents=True, exist_ok=True) + user_codex = Path.home() / ".codex" + for fname in ("auth.json", "installation_id"): + src = user_codex / fname + if src.is_file(): + shutil.copy2(src, codex_home / fname) + return codex_home + + +def register_codex_plugin(workspace: Path) -> None: + """Replicate what codex's interactive `/plugin install` does, headless. + + Must be called AFTER install.sh has written the workspace plugin tree + (it reads from workspace/plugins/evolve-lite/) and AFTER + setup_isolated_codex_home() has created the workspace's CODEX_HOME. + + Three steps are required for `$evolve-lite:` invocations to + resolve via codex's skill registry (proven the hard way — see the + long codex-registration investigation in this file's git history): + + 1. **Marketplace registered** via `codex plugin marketplace add + `. install.sh writes + `/.agents/plugins/marketplace.json` but that's + metadata — codex still needs the explicit `marketplace add` to + record `[marketplaces.evolve-local]` in CODEX_HOME's config.toml. + + 2. **Plugin cache populated** at + `/plugins/cache/evolve-local/evolve-lite//` + with a FLAT skills layout (`skills//SKILL.md`, NOT nested + under `skills/evolve-lite//`). Codex's plugin loader walks + `/skills/<*>/SKILL.md` and ignores plugin.json's `skills` + path field — so we have to flatten on copy even though the + source tree nests skills under `skills/evolve-lite//` for + claude's runtime convention. + + 3. **Plugin enabled in PERSISTED config.toml**, not the `-c + plugins."x@y".enabled=true` per-invocation flag. The flag form + doesn't trigger codex's startup plugin-discovery pass; only the + persisted entry does. + + There is no `codex plugin install` CLI subcommand (only interactive + TUI). Steps 2 and 3 are what `/plugin install evolve-lite@evolve-local` + does manually; we replicate them here for headless runs. + """ + codex_home = workspace / ".codex-home" + + # Step 1: register the workspace as a local marketplace. Writes + # [marketplaces.evolve-local] into CODEX_HOME's config.toml. + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + subprocess.run( + ["codex", "plugin", "marketplace", "add", str(workspace)], + env=env, + capture_output=True, + text=True, + check=True, + ) + + # Step 2: populate the plugin cache with a FLAT skills layout. + plugin_src = workspace / "plugins" / "evolve-lite" + plugin_json = plugin_src / ".codex-plugin" / "plugin.json" + version = json.loads(plugin_json.read_text(encoding="utf-8")).get("version", "0.0.0") + cache_dir = codex_home / "plugins" / "cache" / "evolve-local" / "evolve-lite" / version + cache_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(plugin_src / ".codex-plugin", cache_dir / ".codex-plugin", dirs_exist_ok=True) + shutil.copytree(plugin_src / "lib", cache_dir / "lib", dirs_exist_ok=True) + # Flatten: source has skills/evolve-lite//, cache wants skills//. + shutil.copytree(plugin_src / "skills" / "evolve-lite", cache_dir / "skills", dirs_exist_ok=True) + + # Step 3: persist [plugins."evolve-lite@evolve-local"] in config.toml. + # Append — `marketplace add` already wrote [marketplaces.evolve-local]. + config_toml = codex_home / "config.toml" + with config_toml.open("a", encoding="utf-8") as f: + f.write('\n[plugins."evolve-lite@evolve-local"]\nenabled = true\n') + + +def cleanup_claude_projects(workspace: Path) -> None: + """Remove ~/.claude/projects// trees from this run. + + Claude persists per-cwd state — session transcripts AND auto-memory + writes — under ~/.claude/projects//. The encoding is + fiddly: macOS resolves /var → /private/var via realpath, and claude + also normalizes `_` to `-` in path components. Rather than replicate + those rules, we match by the unique tempdir suffix + (e.g. `evolve-smoke-XXXXXXXX-workspace`) which appears verbatim in the + encoded directory regardless of upstream normalization. Each smoke run + uses a fresh tempdir, so this is unambiguously scoped to one run. + + This also takes care of cleaning up any auto-memory entries the seed + session may have written when it saw the word 'Remember' in the prompt + — those land under /memory/, which is part of the subtree + we're deleting. + """ + suffix = f"{workspace.parent.name}-{workspace.name}" + base = Path.home() / ".claude" / "projects" + if not base.is_dir(): + return + for entry in base.iterdir(): + if entry.is_dir() and suffix in entry.name: + shutil.rmtree(entry, ignore_errors=True) + logger.debug(f"removed claude projects dir {entry}") + + +def install_signal_handlers(cleanup: Callable[[], None]) -> None: + def handler(signum, _frame): + logger.info(f"received signal {signum}; cleaning up") + try: + cleanup() + finally: + sys.exit(128 + signum) + + for sig in (signal.SIGINT, signal.SIGTERM): + signal.signal(sig, handler) + + +# ─── install.sh driver ──────────────────────────────────────────────────────── + + +def _run_install_sh(args: list[str], *, log_file: Path, label: str, check: bool = True) -> int: + """Invoke platform-integrations/install.sh and tee output to the log. + + install.sh auto-detects the local platform-integrations/ tree (it lives + next to it), so no env var or download is needed. + """ + if not INSTALL_SH.is_file(): + raise FileNotFoundError(f"install.sh not found at {INSTALL_SH}") + cmd = ["bash", str(INSTALL_SH), *args] + log_file.parent.mkdir(parents=True, exist_ok=True) + with log_file.open("a", encoding="utf-8") as log: + log.write(f"\n# === {label} ===\n# cmd: {cmd}\n") + log.flush() + proc = subprocess.run(cmd, capture_output=True, text=True) + log.write(f"exit={proc.returncode}\n") + log.write(f"stdout:\n{proc.stdout}\n") + log.write(f"stderr:\n{proc.stderr}\n") + if check and proc.returncode != 0: + raise RuntimeError(f"install.sh {' '.join(args)} failed (exit {proc.returncode}); see {log_file}") + return proc.returncode + + +_INSTALL_EXTRA_ARGS = {"codex": [], "bob": ["--mode", "lite"]} + + +def install_one(platform: str, workspace: Path, log_file: Path) -> None: + """Install a single platform's plugin into its own workspace. + + Claude isn't installed here — it's loaded per-invocation via + `claude --plugin-dir ` (see run_claude). Codex and bob + are installed project-local via install.sh, scoped to `workspace`. + """ + if platform == "claude": + return + extra = _INSTALL_EXTRA_ARGS[platform] + logger.debug(f"running install.sh install --platform {platform} --dir {workspace}") + _run_install_sh( + ["install", "--platform", platform, *extra, "--dir", str(workspace)], + log_file=log_file, + label=f"install.sh install --platform {platform}", + ) + + +def _verify_claude(expected_dir: Path) -> tuple[bool, str]: + """Use `claude plugin list --json` to confirm the session-scoped install + points at `expected_dir`. + + Passing `--plugin-dir ` registers a session-scoped plugin entry with + `scope: "session"` and `installPath: `; the listing surfaces it + alongside the user's globally installed plugins, which is exactly the + signal we want — proof from the host CLI that *this* invocation will + load from , regardless of what's globally installed. + """ + try: + proc = subprocess.run( + ["claude", "--plugin-dir", str(expected_dir), "plugin", "list", "--json"], + capture_output=True, + text=True, + timeout=30, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + return False, f"claude plugin list failed: {exc!r}" + if proc.returncode != 0: + return False, f"`claude plugin list` exit={proc.returncode}: {proc.stderr.strip()}" + try: + data = json.loads(proc.stdout) + except json.JSONDecodeError as exc: + return False, f"unparseable claude plugin list output: {exc}" + + session_entries = [e for e in data if e.get("scope") == "session" and "evolve-lite" in (e.get("id") or "")] + expected_str = str(expected_dir) + for entry in session_entries: + if entry.get("installPath") == expected_str: + return True, f"claude plugin list reports session install at {expected_str}" + if session_entries: + return False, (f"claude session install at {session_entries[0].get('installPath')!r}, expected {expected_str!r}") + return False, f"no session-scoped evolve-lite in `claude --plugin-dir {expected_str} plugin list`" + + +def _verify_codex(workspace: Path) -> tuple[bool, str]: + """Pre-flight checks for codex. + + Two layers, both load-bearing: + + 1. **Marketplace.json points at the workspace plugin tree.** Necessary + but not sufficient — this used to be the *only* check, which gave a + false ✓ when codex actually loaded a stale cached plugin instead of + the workspace one (see CODEX_HOME comment in setup_isolated_codex_home). + + 2. **Isolated CODEX_HOME is set up with auth.** Proves + setup_isolated_codex_home() ran. Without this, codex would either + cache to the user's real ~/.codex/ (defeats isolation) or fail to + authenticate (no auth.json copy). + + A separate post-skill check (verify_codex_cache_matches_workspace) + confirms codex actually loaded from CODEX_HOME after invocation — + that's where the structural proof lives. + """ + expected_plugin = (workspace / "plugins/evolve-lite").resolve() + marketplace_json = workspace / ".agents/plugins/marketplace.json" + if not marketplace_json.is_file(): + return False, f"codex marketplace.json missing at {marketplace_json}" + try: + data = json.loads(marketplace_json.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return False, f"codex marketplace.json malformed: {exc}" + entries = [p for p in data.get("plugins", []) if p.get("name") == "evolve-lite"] + if not entries: + return False, f"no evolve-lite entry in {marketplace_json}" + rel = (entries[0].get("source") or {}).get("path") or "" + # Codex marketplace.json paths are workspace-relative (project root), + # not relative to the marketplace.json file's own directory — that's + # how install.sh writes them (`./plugins/evolve-lite`) and how codex + # resolves them at discovery time. + resolved = (workspace / rel).resolve() if rel else None + if resolved != expected_plugin: + return False, f"codex marketplace.json source resolves to {resolved}, expected {expected_plugin}" + if not (expected_plugin / "skills").is_dir(): + return False, f"codex plugin tree missing under {expected_plugin}" + + codex_home = workspace / ".codex-home" + if not codex_home.is_dir(): + return False, f"isolated CODEX_HOME not set up at {codex_home}" + if not (codex_home / "auth.json").is_file(): + return False, ( + f"auth.json missing in {codex_home}; codex would fail to authenticate. Make sure ~/.codex/auth.json exists on the host." + ) + return True, f"codex marketplace.json points at {expected_plugin}; isolated CODEX_HOME at {codex_home}" + + +def verify_codex_cache_matches_workspace(workspace: Path) -> tuple[bool, str]: + """Post-skill check: codex's plugin cache contains *this* workspace's + plugin source, not a stale or divergent version. + + setup_isolated_codex_home() populates + /.codex-home/plugins/cache/evolve-local/evolve-lite// + with a flattened skills layout (skills//SKILL.md, NOT + skills/evolve-lite//SKILL.md — the latter is what the + source tree has, but codex's plugin loader walks the cache without + honoring plugin.json's `skills` path, so we flatten on copy). + + This check confirms: + * the cache directory exists with at least one version subdir, + * cached recall SKILL.md matches the workspace's source recall + SKILL.md byte-for-byte (proves we copied from the right place + and that nothing overwrote it mid-run). + """ + cache_root = workspace / ".codex-home" / "plugins" / "cache" / "evolve-local" / "evolve-lite" + if not cache_root.is_dir(): + return False, f"codex plugin cache missing at {cache_root}" + versions = sorted(v for v in cache_root.iterdir() if v.is_dir()) + if not versions: + return False, f"cache root exists but no version subdir at {cache_root}" + if len(versions) > 1: + logger.warning(f"multiple codex cache versions: {[v.name for v in versions]}; comparing newest") + cached = versions[-1] + cached_skill = cached / "skills" / "recall" / "SKILL.md" + workspace_skill = workspace / "plugins/evolve-lite/skills/evolve-lite/recall/SKILL.md" + if not workspace_skill.is_file(): + return False, f"workspace recall SKILL.md missing at {workspace_skill}" + if not cached_skill.is_file(): + return False, f"cached recall SKILL.md missing at {cached_skill}" + if cached_skill.read_text(encoding="utf-8") != workspace_skill.read_text(encoding="utf-8"): + return False, (f"cached SKILL.md content != workspace SKILL.md ({cached_skill} vs {workspace_skill}); cache was overwritten") + return True, f"codex cache content matches workspace plugin ({cached})" + + +def _verify_bob(workspace: Path) -> tuple[bool, str]: + """Bob has no CLI listing for project-local skills (`bob extensions list` + only shows extensions under ~/.bob/extensions/, and these are skills + under /.bob/skills/). Per the brief, file presence in the + workspace is enough: bob auto-discovers .bob/ from cwd, so the + presence of skills at the expected path proves the load source.""" + skill = workspace / ".bob/skills/evolve-lite-learn/SKILL.md" + if skill.is_file(): + return True, f"bob skill present at {skill}" + return False, f"bob skill missing at {skill}" + + +def verify_install(platform: str, workspace: Path) -> tuple[bool, str]: + """Pre-flight per-platform: confirm the host CLI will load skills from + the path we installed/pointed it at. See _verify_ docstrings. + """ + if platform == "claude": + return _verify_claude(CLAUDE_PLUGIN_DIR) + if platform == "codex": + return _verify_codex(workspace) + if platform == "bob": + return _verify_bob(workspace) + raise AssertionError(platform) + + +# ─── plugin tree resolution (for direct subscribe.py invocation) ────────────── + + +def plugin_root_for(platform: str, workspace: Path) -> Path: + """Where install.sh placed the plugin tree for `platform`. + + We need this to invoke subscribe.py directly (the smoke test wires its + own bare-remote write-scope subscription rather than asking the LLM to + do it), so the path has to match install.sh's output layout. + """ + if platform == "claude": + # install.sh's claude path doesn't unpack into the workspace; the + # plugin lives under ~/.claude/plugins after `claude plugin install`. + # Repo source has the same content, so use it for subscribe.py. + return PLATFORM_INTEGRATIONS / "claude" / "plugins" / "evolve-lite" + if platform == "codex": + return workspace / "plugins" / "evolve-lite" + if platform == "bob": + return workspace / ".bob" + raise AssertionError(platform) + + +# ─── workspace bootstrap ────────────────────────────────────────────────────── + + +def init_workspace(workspace: Path) -> None: + """Create the workspace directory layout. + + We avoid `uv init` for two reasons: (1) it would pull network deps, and + (2) the smoke test only cares that the host CLI loads the plugin and + exercises the skills against `EVOLVE_DIR` — nothing in the skills cares + about Python project metadata. So we just lay down a minimal repo. + """ + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "pyproject.toml").write_text( + '[project]\nname = "evolve-smoke-workspace"\nversion = "0.0.0"\n', + encoding="utf-8", + ) + (workspace / "README.md").write_text("# evolve-smoke-workspace\n", encoding="utf-8") + + +def find_marker_in_trajectory(evolve_dir: Path, marker: str) -> tuple[bool, Path | None]: + """Look for `marker` in the most recent saved trajectory. + + Used for claude's recall check: claude's on_stop hook fires + /evolve-lite:learn after every `claude -p` invocation, and the parent + agent's post-learn response clobbers stdout — so the recall response + that contains the seeded marker is no longer in captured stdout. The + save-trajectory Stop hook (running before learn's Stop hook) writes + the full transcript to ${EVOLVE_DIR}/trajectories/, which preserves + the parent's recall response (and the forked recall sub-agent's + tool_result, which is where the verbatim entity quote actually + appears thanks to the forked_context branch in recall/SKILL.md.j2). + Grepping that file lets us verify recall succeeded even when the + final stdout is about a different skill. + """ + traj_dir = evolve_dir / "trajectories" + if not traj_dir.is_dir(): + return False, None + candidates = list(traj_dir.glob("*.jsonl")) + list(traj_dir.glob("*.json")) + if not candidates: + return False, None + latest = max(candidates, key=lambda p: p.stat().st_mtime) + try: + text = latest.read_text(encoding="utf-8", errors="replace") + except OSError: + return False, None + return (marker in text), latest + + +def entity_count(evolve_dir: Path) -> int: + """Count entity .md files under ${EVOLVE_DIR}/entities//. + + Excludes the `subscribed/` subtree, which is the cloned write-scope repo + (its own files come from publish, not learn) and would muddy the count. + """ + entities_dir = evolve_dir / "entities" + if not entities_dir.is_dir(): + return 0 + total = 0 + for sub in entities_dir.iterdir(): + if not sub.is_dir() or sub.name == "subscribed": + continue + total += sum(1 for _ in sub.rglob("*.md")) + return total + + +SEED_PROMPT = ( + "Remember that this project is managed by uv. " + "Add the `requests` package as a dependency, then write a one-line " + "Python script that imports requests and prints its version. " + "Run the script and report the version it printed." +) + + +def run_claude_seed(workspace: Path, evolve_dir: Path, log_file: Path) -> int: + """Run a claude -p task that organically produces a tool-failure cycle. + + Why: learn's Step 2 looks for tool failures, retries, and corrections in + the trajectory. Without a meaningful seed, learn correctly emits zero + entities — which masks a broken extractor as a passing test. + + The prompt: a uv-managed-project constraint plus a 'add requests, run a + script' task. The model typically reaches for `pip install` or + `python3 -c 'import requests'` first, hits ModuleNotFoundError or a + pip-vs-uv mismatch, and recovers via `uv add requests` + `uv run`. The + 'Remember' framing is intentional — even though it can engage claude's + auto-memory feature, anything written lives under the tempdir-scoped + ~/.claude/projects//memory/ that cleanup_claude_projects + deletes at the end of the run. + + Side-effect chain (claude only): + 1. seed session runs the task and exits. + 2. save-trajectory Stop hook copies the transcript to + ${EVOLVE_DIR}/trajectories/claude-transcript_.jsonl. + 3. learn Stop hook blocks the agent, claude re-engages with + /evolve-lite:learn, the forked sub-agent reads the trajectory and + saves entities to ${EVOLVE_DIR}/entities/. + """ + rc, _ = run_claude(SEED_PROMPT, cwd=workspace, evolve_dir=evolve_dir, log_file=log_file, label="seed") + return rc + + +def seed_recall_entity(evolve_dir: Path, marker: str) -> Path: + guideline_dir = evolve_dir / "entities" / "guideline" + guideline_dir.mkdir(parents=True, exist_ok=True) + path = guideline_dir / "smoke-recall-seed.md" + path.write_text( + textwrap.dedent( + f"""\ + --- + type: guideline + trigger: When running the evolve-lite smoke test + --- + + {marker} — this is the seeded recall entity. If you can read this, + recall is wired correctly end-to-end. + + ## Rationale + + Smoke-test marker for the recall skill. + """ + ), + encoding="utf-8", + ) + return path + + +def seed_publish_entity(evolve_dir: Path, name: str) -> Path: + guideline_dir = evolve_dir / "entities" / "guideline" + guideline_dir.mkdir(parents=True, exist_ok=True) + path = guideline_dir / name + path.write_text( + textwrap.dedent( + """\ + --- + type: guideline + trigger: When the smoke test exercises publish + --- + + Smoke-test guideline destined for the bare git remote. + + ## Rationale + + If this lands as a commit on the bare remote, publish works end-to-end. + """ + ), + encoding="utf-8", + ) + return path + + +# ─── bare git remote (publish target) ───────────────────────────────────────── + + +def init_bare_remote(remote_path: Path) -> None: + """Create a bare repo with a single empty commit on `main` so subscribe + can clone it without `error: Remote branch main not found`.""" + remote_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "init", "--bare", "--initial-branch=main", str(remote_path)], + check=True, + capture_output=True, + ) + seed_dir = remote_path.parent / f"_seed_{remote_path.name}" + seed_dir.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + # Force a stable identity so `git commit` doesn't error in CI-like envs. + env.setdefault("GIT_AUTHOR_NAME", "Smoke Bot") + env.setdefault("GIT_AUTHOR_EMAIL", "smoke@example.invalid") + env.setdefault("GIT_COMMITTER_NAME", "Smoke Bot") + env.setdefault("GIT_COMMITTER_EMAIL", "smoke@example.invalid") + for cmd in ( + ["git", "init", "-b", "main"], + ["git", "commit", "--allow-empty", "-m", "init"], + ["git", "remote", "add", "origin", str(remote_path)], + ["git", "push", "origin", "main"], + ): + subprocess.run(cmd, cwd=seed_dir, check=True, capture_output=True, env=env) + shutil.rmtree(seed_dir, ignore_errors=True) + + +def remote_commit_count(remote_path: Path) -> int: + """Number of commits reachable from `main` on the bare remote.""" + result = subprocess.run( + ["git", "--git-dir", str(remote_path), "rev-list", "--count", "main"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return -1 + return int((result.stdout or "0").strip() or 0) + + +def remote_log(remote_path: Path) -> str: + result = subprocess.run( + ["git", "--git-dir", str(remote_path), "log", "--oneline", "main"], + capture_output=True, + text=True, + check=False, + ) + return (result.stdout or result.stderr or "").strip() + + +# ─── subscribe shortcut (bypasses LLM for repo wiring) ──────────────────────── + + +def subscribe_write_repo(plugin_root: Path, evolve_dir: Path, remote: Path, repo_name: str) -> None: + """Run subscribe.py directly so the publish step has a configured target. + + The smoke goal is to exercise publish end-to-end via the LLM, but we don't + need the LLM to set up subscriptions — that's a config-mechanic step the + user typically does once. Driving subscribe.py directly keeps the smoke + test focused on what's actually load-bearing: publish writes to the + cloned write-scope repo, then the agent commits and pushes. + """ + # Locate the subscribe script. claude/codex use skills/evolve-lite/subscribe; + # bob uses skills/evolve-lite-subscribe. + candidates = [ + plugin_root / "skills" / "evolve-lite" / "subscribe" / "scripts" / "subscribe.py", + plugin_root / "skills" / "evolve-lite-subscribe" / "scripts" / "subscribe.py", + ] + subscribe_py = next((c for c in candidates if c.is_file()), None) + if subscribe_py is None: + raise FileNotFoundError(f"subscribe.py not found under {plugin_root}") + + env = os.environ.copy() + env["EVOLVE_DIR"] = str(evolve_dir) + subprocess.run( + [ + sys.executable, + str(subscribe_py), + "--name", + repo_name, + "--remote", + str(remote), + "--scope", + "write", + "--branch", + "main", + ], + check=True, + capture_output=True, + text=True, + env=env, + ) + + +def write_identity(project_root: Path, user: str = "smoke-bot") -> None: + """publish.py reads identity.user from evolve.config.yaml and stamps it + on the published entity. subscribe.py wrote the `repos:` section already; + we only need to add `identity:` to the same file. + + `project_root` is the directory holding evolve.config.yaml. With + EVOLVE_DIR=/.evolve, subscribe.py treats as + the project root (the `.evolve` name triggers parent-as-root logic + in subscribe.py:67), so pass the workspace here. + """ + cfg_path = project_root / "evolve.config.yaml" + existing = cfg_path.read_text(encoding="utf-8") if cfg_path.exists() else "" + if "identity:" not in existing: + cfg_path.write_text(f"identity:\n user: {user}\n{existing}", encoding="utf-8") + + +# ─── command runners (one per host) ─────────────────────────────────────────── + + +def _bytes_or_str_to_str(x: str | bytes | None) -> str: + if x is None: + return "" + if isinstance(x, bytes): + return x.decode("utf-8", errors="replace") + return x + + +def _run( + cmd: list[str], + *, + cwd: Path, + env: dict[str, str], + log_file: Path, + label: str, +) -> tuple[int, str]: + """Run a host CLI command, tee output to a log, return (exit_code, output).""" + log_file.parent.mkdir(parents=True, exist_ok=True) + with log_file.open("a", encoding="utf-8") as log: + log.write(f"\n# === {label} ===\n# cmd: {cmd}\n# cwd: {cwd}\n") + log.flush() + try: + proc = subprocess.run( + cmd, + cwd=str(cwd), + env=env, + capture_output=True, + text=True, + timeout=PER_INVOCATION_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + # subprocess sets text=True so exc.stdout/stderr are str at + # runtime, but the typeshed signature is `str | bytes | None` + # (bytes when text/universal_newlines is unset). Coerce defensively + # so mypy is happy and a future caller flipping text=False doesn't + # silently print `b'...'`. + so_out = _bytes_or_str_to_str(exc.stdout) + so_err = _bytes_or_str_to_str(exc.stderr) + log.write(f"TIMEOUT after {PER_INVOCATION_TIMEOUT_SECONDS}s\n") + log.write(f"stdout-so-far:\n{so_out}\n") + log.write(f"stderr-so-far:\n{so_err}\n") + return 124, so_out + so_err + log.write(f"exit={proc.returncode}\n") + log.write(f"stdout:\n{proc.stdout}\n") + log.write(f"stderr:\n{proc.stderr}\n") + return proc.returncode, proc.stdout + "\n" + proc.stderr + + +def run_claude(prompt: str, *, cwd: Path, evolve_dir: Path, log_file: Path, label: str) -> tuple[int, str]: + env = os.environ.copy() + env["EVOLVE_DIR"] = str(evolve_dir) + # --plugin-dir points claude at this repo's rendered claude plugin tree + # for the duration of this invocation, sidestepping the user's real + # ~/.claude/ install. This is install.sh's documented manual fallback + # (see platform-integrations/INSTALL_SPEC.md). + # + # We deliberately do NOT pass --no-session-persistence: that flag stops + # claude from writing the transcript to ~/.claude/projects/, which + # leaves save-trajectory's Stop hook with no `transcript_path` file to + # copy into ${EVOLVE_DIR}/trajectories/ — so learn would have nothing + # to extract from. Persistence pollutes ~/.claude/projects/ with one + # `/` subtree per smoke run, which we explicitly clean + # up in `cleanup_claude_projects()`. + cmd = [ + "claude", + "--plugin-dir", + str(CLAUDE_PLUGIN_DIR), + "--dangerously-skip-permissions", + "-p", + prompt, + ] + return _run(cmd, cwd=cwd, env=env, log_file=log_file, label=label) + + +def run_codex(prompt: str, *, cwd: Path, evolve_dir: Path, log_file: Path, label: str) -> tuple[int, str]: + env = os.environ.copy() + env["EVOLVE_DIR"] = str(evolve_dir) + # CODEX_HOME points at the workspace-local codex home set up by + # setup_isolated_codex_home(); ensures codex's plugin cache lives + # under the tempdir and gets wiped with it. setup_isolated_codex_home + # also writes a fresh config.toml with the marketplace+plugin entries + # codex needs to register `$evolve-lite:` into its skill + # registry — see that function's docstring for why all three steps + # are required. We do NOT pass --ignore-user-config: from codex's + # perspective $CODEX_HOME/config.toml IS the user config, and + # ignoring it would skip the persisted [plugins."evolve-lite@..."] + # entry that triggers plugin discovery at startup. + env["CODEX_HOME"] = str(cwd / ".codex-home") + cmd = [ + "codex", + "exec", + "--skip-git-repo-check", + "--ephemeral", + "--dangerously-bypass-approvals-and-sandbox", + "-c", + "features.codex_hooks=true", + "-C", + str(cwd), + prompt, + ] + return _run(cmd, cwd=cwd, env=env, log_file=log_file, label=label) + + +def run_bob(prompt: str, *, cwd: Path, evolve_dir: Path, log_file: Path, label: str) -> tuple[int, str]: + env = os.environ.copy() + env["EVOLVE_DIR"] = str(evolve_dir) + cmd = [ + "bob", + "--yolo", + "--hide-intermediary-output", + prompt, + ] + return _run(cmd, cwd=cwd, env=env, log_file=log_file, label=label) + + +# ─── per-platform driver ────────────────────────────────────────────────────── + + +@dataclass +class PlatformPlan: + name: str + cli: str # binary on PATH + learn_cmd: str # slash command text to send for learn + publish_cmd: str # slash command text to invoke publish + recall_prompt: str # full prompt for recall + + +def claude_plan() -> PlatformPlan: + return PlatformPlan( + name="claude", + cli="claude", + learn_cmd="/evolve-lite:learn", + publish_cmd="/evolve-lite:publish", + recall_prompt=( + "Use /evolve-lite:recall on this conversation. After running it, " + "quote any retrieved entity content verbatim — do not paraphrase. " + "If nothing is retrieved, say 'NO ENTITIES FOUND'." + ), + ) + + +def codex_plan() -> PlatformPlan: + # All codex invocations use `$` — that's the registry-lookup + # form (see openai/codex#11817). The whole point of this smoke test + # is to verify the plugin's skills are actually installed and + # discoverable by codex's runtime, NOT just that SKILL.md exists on + # disk for the model to fall back on. If a `$` invocation + # fails because codex says "skill not in available list", that's a + # real install/registration bug, not a prompt issue. + return PlatformPlan( + name="codex", + cli="codex", + learn_cmd="$evolve-lite:learn", + publish_cmd="$evolve-lite:publish", + recall_prompt=( + "Use $evolve-lite:recall on this conversation. After running it, " + "quote any retrieved entity content verbatim — do not paraphrase. " + "If nothing is retrieved, say 'NO ENTITIES FOUND'." + ), + ) + + +def bob_plan() -> PlatformPlan: + # Bob's commands are flat-named: /evolve-lite-recall, /evolve-lite-learn, etc. + return PlatformPlan( + name="bob", + cli="bob", + learn_cmd="/evolve-lite-learn", + publish_cmd="/evolve-lite-publish", + recall_prompt=( + "Run /evolve-lite-recall on this conversation. After running it, " + "quote any retrieved entity content verbatim — do not paraphrase. " + "If nothing is retrieved, say 'NO ENTITIES FOUND'." + ), + ) + + +def cli_present(name: str) -> bool: + return shutil.which(name) is not None + + +def run_platform(platform: str, root_tempdir: Path) -> PlatformResult: + """Self-contained per-platform run: setup + skill flow. + + Each platform owns its own subdir under root_tempdir, so multiple + `run_platform` calls can execute concurrently without sharing + workspace, remotes, or log files. The thread name (set by main's + ThreadPoolExecutor wrapper) becomes the logging prefix. + """ + threading.current_thread().name = platform + result = PlatformResult(platform=platform) + + plan = {"claude": claude_plan(), "codex": codex_plan(), "bob": bob_plan()}[platform] + + if not cli_present(plan.cli): + result.skipped_reason = f"`{plan.cli}` not found on PATH" + log_status("-", "skipped", result.skipped_reason) + return result + + # Per-platform tempdir: //{workspace, remote.git, smoke.log} + pdir = root_tempdir / platform + workspace = pdir / "workspace" + log_file = pdir / "smoke.log" + result.log_path = log_file + + # ── install + verify (per-platform; concurrent-safe since each thread + # has its own workspace). + try: + init_workspace(workspace) + if platform == "codex": + # Must come before any codex invocation. Sets up an isolated + # plugin cache under /.codex-home/ so the user's + # global ~/.codex/plugins/cache/ is never read or written. + setup_isolated_codex_home(workspace) + install_one(platform, workspace, log_file) + if platform == "codex": + # Replicates the interactive `/plugin install` flow: registers + # the marketplace, populates the plugin cache with a flattened + # skills layout, and persists [plugins."x@y"].enabled=true in + # CODEX_HOME's config.toml. Without this, the workspace's + # marketplace.json is just metadata — codex's $-registry never + # actually picks up the plugin's skills. + register_codex_plugin(workspace) + except Exception as exc: + result.setup_error = f"install failed: {exc!r}" + log_status("✗", "install", result.setup_error) + return result + + ok, detail = verify_install(platform, workspace) + if ok: + log_status("✓", "install", detail) + else: + result.setup_error = f"install verify failed: {detail}" + log_status("✗", "install", result.setup_error) + return result + + # ── bob: skill execution disabled. + # Bob has no way to run a slash command non-interactively from a + # one-shot prompt — the end-to-end skill flow (seed → save-trajectory + # → learn → recall → publish) needs each `/skill` invocation to land + # as a real user message, and bob's headless `bob ""` form + # doesn't expose that path. We report bob as "install verified only" + # and skip skill invocations; the install path is the only thing + # this smoke can honestly verify on bob right now. + if platform == "bob": + record_skill( + result, + "install-only", + True, + "skill execution skipped (no way to run slash commands non-interactively on bob)", + ) + return result + + # Use the canonical `.evolve/` name so the codex skill's SKILL.md + # path defaults (`${EVOLVE_DIR:-.evolve}` interpreted by the model as + # the literal `.evolve`) point at the right entities directory. + # subscribe.py treats `evolve_dir.name == ".evolve"` as "I'm the + # .evolve subdir of project_root=parent", which means the workspace + # itself is the project root (where evolve.config.yaml lives) — see + # write_identity(workspace, ...) below. Each platform runs in its + # own workspace subdir, so the unsuffixed name doesn't collide. + evolve_dir = workspace / ".evolve" + evolve_dir.mkdir(parents=True, exist_ok=True) + logger.debug(f"workspace: {workspace}") + logger.debug(f"evolve_dir: {evolve_dir}") + logger.debug(f"log: {log_file}") + + plugin_root = plugin_root_for(platform, workspace) + + # ── bare remote + subscribe (publish target wiring) + try: + remote_path = pdir / "remote.git" + init_bare_remote(remote_path) + subscribe_write_repo(plugin_root, evolve_dir, remote_path, "smoke-target") + # subscribe.py treats EVOLVE_DIR=/.evolve as " is project root", + # so evolve.config.yaml lives at the workspace level, not inside .evolve/. + write_identity(workspace) + baseline_commits = remote_commit_count(remote_path) + logger.debug(f"baseline commit count on {remote_path.name}: {baseline_commits}") + except Exception as exc: + result.setup_error = f"setup failed: {exc!r}" + log_status("✗", "setup", result.setup_error) + return result + + # ── invocation helper bound to this platform's runner + def invoke(prompt: str, label: str) -> tuple[int, str]: + if platform == "claude": + return run_claude(prompt, cwd=workspace, evolve_dir=evolve_dir, log_file=log_file, label=label) + if platform == "codex": + return run_codex(prompt, cwd=workspace, evolve_dir=evolve_dir, log_file=log_file, label=label) + if platform == "bob": + return run_bob(prompt, cwd=workspace, evolve_dir=evolve_dir, log_file=log_file, label=label) + raise AssertionError(platform) + + # ── learn + # All three platforms: real seed task, then verify entity count grew. + # The chain differs by platform — see the module docstring for why: + # * claude: seed task alone; Stop hooks auto-fire save-trajectory + learn, + # and we do an extra explicit /evolve-lite:learn pass afterwards. + # * codex/bob: no Stop hooks for this. Suffix the seed prompt with the + # learn slash command so the same session invokes learn at the end + # (learn is main-context on those platforms — build_plugins.py only + # sets forked_context=True for claude — so it reads the conversation + # directly, no trajectory file needed). + baseline_entities = entity_count(evolve_dir) + if platform == "claude": + t0 = time.time() + seed_rc = run_claude_seed(workspace, evolve_dir, log_file) + dt_seed = time.time() - t0 + post_seed = entity_count(evolve_dir) + logger.debug(f"seed: exit={seed_rc} in {dt_seed:.1f}s; entities {baseline_entities}→{post_seed}") + if seed_rc != 0: + logger.debug(f"seed exited {seed_rc}; learn may have nothing to extract") + + t0 = time.time() + rc, _ = invoke(plan.learn_cmd, "learn") + dt = time.time() - t0 + else: + seed_and_learn_prompt = ( + f"{SEED_PROMPT}\n\n" + f"After completing (or attempting) the task above, your final " + f"action MUST be to run {plan.learn_cmd} so it can extract " + f"learnings from this conversation." + ) + t0 = time.time() + rc, _ = invoke(seed_and_learn_prompt, "seed-and-learn") + dt = time.time() - t0 + + post_learn = entity_count(evolve_dir) + ok = (rc == 0) and (post_learn > baseline_entities) + if not ok and rc == 0: + detail = f"exit=0 in {dt:.1f}s but entities still {post_learn} (baseline {baseline_entities}); learn extracted nothing" + else: + detail = f"exit={rc} in {dt:.1f}s; entities {baseline_entities}→{post_learn}" + record_skill(result, "learn", ok, detail) + + # ── recall (seed entity, prompt agent to echo it) + marker = f"MARKER_{uuid.uuid4().hex[:12]}" + seed_recall_entity(evolve_dir, marker) + t0 = time.time() + rc, output = invoke(plan.recall_prompt, "recall") + dt = time.time() - t0 + if rc != 0: + record_skill(result, "recall", False, f"exit={rc} in {dt:.1f}s") + elif marker in output: + record_skill(result, "recall", True, f"marker echoed in {dt:.1f}s") + else: + # Stdout fallback for claude: the on_stop learn hook fires after + # every `claude -p` invocation and its post-learn response + # clobbers stdout. The parent's actual recall response (with the + # forked sub-agent's verbatim entity quote) is preserved in the + # saved trajectory file, which we can grep for the marker. + in_traj = False + traj: Path | None = None + if platform == "claude": + in_traj, traj = find_marker_in_trajectory(evolve_dir, marker) + if in_traj and traj is not None: + record_skill( + result, + "recall", + True, + f"marker found in trajectory ({traj.name}) in {dt:.1f}s (stdout clobbered by on_stop)", + ) + else: + record_skill( + result, + "recall", + False, + f"exit=0 in {dt:.1f}s but marker {marker!r} absent from output and trajectory (see log)", + ) + + # ── publish (seed guideline, drive the slash command, verify bare remote) + publish_filename = f"smoke-publish-{uuid.uuid4().hex[:8]}.md" + seed_publish_entity(evolve_dir, publish_filename) + publish_prompt = ( + f"Run the publish skill ({plan.publish_cmd}). " + f"Publish exactly the file `{publish_filename}` from the configured EVOLVE_DIR " + f"({evolve_dir})/entities/guideline/ to the configured write-scope repo `smoke-target`. " + f"The user is `smoke-bot`. After the publish.py script succeeds, " + f"`git -C {evolve_dir}/entities/subscribed/smoke-target add guideline/{publish_filename} && " + f"git -C {evolve_dir}/entities/subscribed/smoke-target commit -m '[evolve] publish: {publish_filename}' && " + f"git -C {evolve_dir}/entities/subscribed/smoke-target push origin main`. " + f"Do not ask for confirmation; proceed end-to-end." + ) + t0 = time.time() + rc, _ = invoke(publish_prompt, "publish") + dt = time.time() - t0 + after_commits = remote_commit_count(remote_path) + if rc != 0: + record_skill(result, "publish", False, f"exit={rc} in {dt:.1f}s") + elif after_commits > baseline_commits: + record_skill( + result, + "publish", + True, + f"bare remote went {baseline_commits}→{after_commits} commits in {dt:.1f}s", + ) + else: + record_skill( + result, + "publish", + False, + (f"exit=0 in {dt:.1f}s but no new commit on bare remote (still {after_commits} commits). Last log:\n{remote_log(remote_path)}"), + ) + + # ── cache integrity (codex only): codex's plugin cache must mirror + # the workspace plugin tree. If it doesn't, the smoke ran against the + # wrong source and any pass results above are suspect. + if platform == "codex": + ok, detail = verify_codex_cache_matches_workspace(workspace) + record_skill(result, "cache-integrity", ok, detail) + + return result + + +# ─── orchestrator ───────────────────────────────────────────────────────────── + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--platform", + choices=(*PLATFORMS, "all"), + default="all", + help="Which platform(s) to test (default: all)", + ) + parser.add_argument( + "--keep", + action="store_true", + help="Don't delete the tempdir on exit (for debugging)", + ) + parser.add_argument( + "--sequential", + action="store_true", + help="Run platforms one at a time instead of in parallel (for debugging interleaving issues).", + ) + parser.add_argument( + "--no-live", + action="store_true", + help="Force line-prefix output even on a TTY (default: live grouped view in a TTY when running multi-platform).", + ) + parser.add_argument("--verbose", action="store_true", help="Chatty output") + args = parser.parse_args(argv) + + targets = PLATFORMS if args.platform == "all" else (args.platform,) + + # Live-grouped TTY view only makes sense for parallel multi-platform runs + # in an interactive terminal. Anywhere output is captured (pipe, file, + # agent harness) we drop down to line-prefix mode so the captured stream + # is greppable plain text without ANSI escapes. + live_view = sys.stdout.isatty() and not args.no_live and not args.sequential and len(targets) > 1 + log_handler = setup_logging( + args.verbose, + live=live_view, + group_order=tuple(targets), + ) + + logger.info(f"targets: {', '.join(targets)}") + + tempdir = make_root_tempdir() + cleanup_done = {"flag": False} + + def cleanup() -> None: + # Idempotent — both the signal handler and the finally clause may call us. + # Each platform owns a workspace under tempdir//workspace; we + # also remove claude's per-cwd ~/.claude/projects// entries + # for any workspace that may have triggered persistence. + if cleanup_done["flag"]: + return + cleanup_done["flag"] = True + for plat in targets: + cleanup_claude_projects(tempdir / plat / "workspace") + if args.keep: + logger.info(f"--keep set; leaving {tempdir}") + return + shutil.rmtree(tempdir, ignore_errors=True) + logger.info(f"removed {tempdir}") + + install_signal_handlers(cleanup) + + results: list[PlatformResult] = [] + try: + if args.sequential or len(targets) == 1: + for platform in targets: + results.append(_run_platform_safely(platform, tempdir)) + else: + # Concurrent: each platform runs in its own thread with its own + # workspace, log file, and bare remote. ThreadPoolExecutor's worker + # threads inherit the root logger config, so logs from each thread + # land on the shared StreamHandler atomically (one record per write + # call) — the threadName field in the format string identifies the + # source platform without any manual locking. + with ThreadPoolExecutor(max_workers=len(targets), thread_name_prefix="smoke") as ex: + futures = {ex.submit(_run_platform_safely, p, tempdir): p for p in targets} + for fut in as_completed(futures): + results.append(fut.result()) + # Re-sort to match input order so the summary reads top-to-bottom. + order = {p: i for i, p in enumerate(targets)} + results.sort(key=lambda r: order.get(r.platform, len(targets))) + finally: + cleanup() + if isinstance(log_handler, LiveGroupedHandler): + log_handler.finalize() + if args.keep: + print(f"tempdir kept at: {tempdir}") + + return 1 if any(not r.overall_ok for r in results) else 0 + + +def _run_platform_safely(platform: str, tempdir: Path) -> PlatformResult: + """Wrapper that catches unexpected exceptions so one platform crashing + can't prevent the others from completing or being summarized. + """ + try: + return run_platform(platform, tempdir) + except Exception as exc: + logger.warning(f"unhandled exception in {platform}: {exc!r}") + return PlatformResult(platform=platform, setup_error=f"unhandled: {exc!r}") + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))