From 134447cc3829f6f15a3ab71a9ade66d377c5c815 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:16:50 -0700 Subject: [PATCH 01/33] docs(adr): record compile-time plugin code generation decision Captures the design that came out of the planning session for #219: treat platform-integrations/ as generated output from a new plugin-source/ canonical tree, rendered via Jinja2, with a CI gate enforcing render-equality. Records the alternatives weighed (symlinks, separate repo, gitignored output, Go/Rust tooling) and their rejection reasons so the decision isn't relitigated later. Establishes docs/adr/ as the project's ADR home. Refs #219 --- .../0001-compile-time-plugin-generation.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/adr/0001-compile-time-plugin-generation.md diff --git a/docs/adr/0001-compile-time-plugin-generation.md b/docs/adr/0001-compile-time-plugin-generation.md new file mode 100644 index 00000000..d3e7e585 --- /dev/null +++ b/docs/adr/0001-compile-time-plugin-generation.md @@ -0,0 +1,70 @@ +# 1. Compile-time plugin code generation + +- **Status:** Accepted +- **Date:** 2026-04-29 +- **Tracking issue:** [#219](https://github.com/AgentToolkit/altk-evolve/issues/219) + +## Context + +Each entry under `platform-integrations/` (`bob`, `claude`, `codex`, `claw-code`) carries a hand-edited copy of the same conceptual `evolve-lite` plugin — same skills, similar `lib/`, similar scripts, similar prose. Drift is the dominant failure mode: PRs #188, #196, #199, and #230 each landed a fix in one platform's copy without updating the others, and the auto-memory note `evolve-lite has three variants` exists specifically because contributors keep forgetting Bob (whose paths are also structurally different — a `evolve-lite:/` colon-prefixed flat layout that is incompatible with Windows). When `save_entities.py` is compared across the three platforms, some divergence reflects genuine per-platform need (lib-path discovery in different runtime environments) but most of it is unintentional drift (e.g., the `entity["owner"]` stamping logic differs without rationale). + +There is no enforcement mechanism that the four copies stay in sync, no single source of truth for shared content, and no clear "edit here" location for maintainers or AI agents who are asked to modify a skill. + +## Decision + +Treat `platform-integrations/` as **generated output**. Add a new top-level `plugin-source/` directory that is the single canonical source for all four platforms' plugin code. A small Python build script renders `plugin-source/` into the per-platform trees under `platform-integrations/` using Jinja2 templating. The generated tree remains committed to the repository so that PR review, agent comprehension, and `git log` all continue to work without requiring readers to run a build first. A pre-commit hook and CI gate enforce that the committed output matches a fresh render of the source. + +Per-file variation is expressed in three layered ways, applied per file as appropriate: +- *Per-platform shim modules* for code variation that's structurally factorable (e.g. lib-path discovery). The canonical script body remains valid lintable Python and imports from the shim. +- *Jinja2 conditionals* for prose variation tuned per audience LLM (e.g. `SKILL.md`). +- *Per-platform full-file overlays* for files unique to one platform (e.g. Claude's `on_stop.py` hook), and for files where prose divergence is so substantial that conditionals would harm readability. +- Files with no variation are copied verbatim by the build. + +On-disk paths are colon-free everywhere (Windows compatibility). Bob's existing `evolve-lite:/` directory naming is replaced with `evolve-lite-/`. The user-facing `evolve-lite:` namespace is preserved at the invocation layer (Claude/Codex via plugin manifest; Bob via the `name:` frontmatter in `SKILL.md` if it accepts colons there, with documented fallback if not). + +## Alternatives Considered + +### Option A — `platform-integrations/common/` with symlinks back into per-platform trees + +The shared content lives in a `common/` folder; each platform tree links to it. Backwards-compatible at the filesystem level if symlinks are followed. + +Rejected because: the install step would still need to dereference symlinks (the issue raised the question of "dereference any symlinks created" up front), so this approach already implies a build step at install time — symlinks are not actually saving us a build, just hiding it. Symlinks also do not handle Bob's structural rename problem (different on-disk layout), and they cannot express per-file content variation (e.g. the lib-path discovery prelude that genuinely differs by platform). Editor and tool behavior with symlinks is also inconsistent across platforms. + +### Option B — Move generated plugin code to a separate repository + +The unified source lives here; the per-platform output is published to a sibling repo. + +Rejected because: refactors that span the engine and the rendered output would no longer be atomic — a contributor changing a shared template plus its rendered result would need two PRs across two repos in lockstep, with no way for CI to enforce coherence between them. The "less perceived overhead" of a smaller home repo is illusory: the cross-repo sync overhead is strictly worse than local clutter that can be marked as generated. + +### Option C — Generated tree gitignored, built fresh by the installer + +Source is canonical; rendered output never lands in git. The installer (or a post-clone hook) builds it on demand. + +Rejected because: PR reviewers (human and agent) lose visibility into what actually changed in the rendered output for a given source change. Fresh checkouts can't be inspected without first running the build. `git log -- platform-integrations/...` becomes useless. The PR-review story is the dominant cost. + +### Option D — Author a build tool in Go or Rust + +A standalone static binary instead of Python+Jinja2. + +Rejected because: the build only runs in the developer/CI loop, never on user machines (end users continue to use `install.sh` against the committed generated tree). The project is already Python-via-`uv`, so the Python+Jinja2 path adds zero new toolchain. A Go/Rust binary would add a build/release pipeline for the tool itself with no compensating benefit at the user-facing layer. + +## Consequences + +### Positive + +- A single canonical edit location for maintainers and agents. The auto-memory note `evolve-lite has three variants` becomes obsolete. +- Drift between platform copies is mechanically prevented (CI fails when committed output ≠ fresh render). +- Adding a new platform integration becomes a config-file addition rather than a copy-paste, lowering future maintenance cost. +- Synthesis of the three drifted versions of `save_entities.py` (and similar files) is captured once, in the canonical source, with documented rationale. +- Bob's Windows incompatibility is fixed in passing. + +### Negative + +- The repository now has *both* hand-edited source (`plugin-source/`) and generated output (`platform-integrations/`) in tree. Contributors must be aware of the distinction. Mitigated by `.generated` marker files in each generated subtree and `# DO NOT EDIT` headers on rendered files where comment syntax allows. +- The build step adds a small but real friction to every change. Mitigated by the `just compile-plugins` recipe and the pre-commit hook that runs it automatically. +- Rendered files are no longer plain Python (they're rendered from `.j2` templates), so IDE tooling on the generated copy is "view-only". Maintainers edit the canonical source where Python files remain valid Python (per the shim-module override pattern); the `.j2` files are mostly markdown and don't lose linting. +- Bob's user-facing slash-command surface may change (`/evolve-lite:learn` → `/evolve-lite-learn`) if Bob's `name:` frontmatter rejects colons. Verified during implementation; documented either way. + +### Migration + +Single PR, multi-commit, per-commit CI green. Stages: (0) this ADR; (1) introduce build pipeline rendering byte-identically to current `platform-integrations/`; (2) synthesize drifted files; (3) rename Bob colon paths; (4) decouple `custom_modes.yaml` from skill enumeration. Each stage is a commit reviewable in isolation and revertable on its own. From e7b48e634a36ff25afed3d8e864783caeaf26d4d Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:25:39 -0700 Subject: [PATCH 02/33] docs: drop the ADR draft (#219 PRD already captures the rationale) The PRD on #219 is the canonical record of the design decisions for this work. A separate ADR file duplicated that content without adding review value, so it has been removed. Refs #219 --- .../0001-compile-time-plugin-generation.md | 70 ------------------- 1 file changed, 70 deletions(-) delete mode 100644 docs/adr/0001-compile-time-plugin-generation.md diff --git a/docs/adr/0001-compile-time-plugin-generation.md b/docs/adr/0001-compile-time-plugin-generation.md deleted file mode 100644 index d3e7e585..00000000 --- a/docs/adr/0001-compile-time-plugin-generation.md +++ /dev/null @@ -1,70 +0,0 @@ -# 1. Compile-time plugin code generation - -- **Status:** Accepted -- **Date:** 2026-04-29 -- **Tracking issue:** [#219](https://github.com/AgentToolkit/altk-evolve/issues/219) - -## Context - -Each entry under `platform-integrations/` (`bob`, `claude`, `codex`, `claw-code`) carries a hand-edited copy of the same conceptual `evolve-lite` plugin — same skills, similar `lib/`, similar scripts, similar prose. Drift is the dominant failure mode: PRs #188, #196, #199, and #230 each landed a fix in one platform's copy without updating the others, and the auto-memory note `evolve-lite has three variants` exists specifically because contributors keep forgetting Bob (whose paths are also structurally different — a `evolve-lite:/` colon-prefixed flat layout that is incompatible with Windows). When `save_entities.py` is compared across the three platforms, some divergence reflects genuine per-platform need (lib-path discovery in different runtime environments) but most of it is unintentional drift (e.g., the `entity["owner"]` stamping logic differs without rationale). - -There is no enforcement mechanism that the four copies stay in sync, no single source of truth for shared content, and no clear "edit here" location for maintainers or AI agents who are asked to modify a skill. - -## Decision - -Treat `platform-integrations/` as **generated output**. Add a new top-level `plugin-source/` directory that is the single canonical source for all four platforms' plugin code. A small Python build script renders `plugin-source/` into the per-platform trees under `platform-integrations/` using Jinja2 templating. The generated tree remains committed to the repository so that PR review, agent comprehension, and `git log` all continue to work without requiring readers to run a build first. A pre-commit hook and CI gate enforce that the committed output matches a fresh render of the source. - -Per-file variation is expressed in three layered ways, applied per file as appropriate: -- *Per-platform shim modules* for code variation that's structurally factorable (e.g. lib-path discovery). The canonical script body remains valid lintable Python and imports from the shim. -- *Jinja2 conditionals* for prose variation tuned per audience LLM (e.g. `SKILL.md`). -- *Per-platform full-file overlays* for files unique to one platform (e.g. Claude's `on_stop.py` hook), and for files where prose divergence is so substantial that conditionals would harm readability. -- Files with no variation are copied verbatim by the build. - -On-disk paths are colon-free everywhere (Windows compatibility). Bob's existing `evolve-lite:/` directory naming is replaced with `evolve-lite-/`. The user-facing `evolve-lite:` namespace is preserved at the invocation layer (Claude/Codex via plugin manifest; Bob via the `name:` frontmatter in `SKILL.md` if it accepts colons there, with documented fallback if not). - -## Alternatives Considered - -### Option A — `platform-integrations/common/` with symlinks back into per-platform trees - -The shared content lives in a `common/` folder; each platform tree links to it. Backwards-compatible at the filesystem level if symlinks are followed. - -Rejected because: the install step would still need to dereference symlinks (the issue raised the question of "dereference any symlinks created" up front), so this approach already implies a build step at install time — symlinks are not actually saving us a build, just hiding it. Symlinks also do not handle Bob's structural rename problem (different on-disk layout), and they cannot express per-file content variation (e.g. the lib-path discovery prelude that genuinely differs by platform). Editor and tool behavior with symlinks is also inconsistent across platforms. - -### Option B — Move generated plugin code to a separate repository - -The unified source lives here; the per-platform output is published to a sibling repo. - -Rejected because: refactors that span the engine and the rendered output would no longer be atomic — a contributor changing a shared template plus its rendered result would need two PRs across two repos in lockstep, with no way for CI to enforce coherence between them. The "less perceived overhead" of a smaller home repo is illusory: the cross-repo sync overhead is strictly worse than local clutter that can be marked as generated. - -### Option C — Generated tree gitignored, built fresh by the installer - -Source is canonical; rendered output never lands in git. The installer (or a post-clone hook) builds it on demand. - -Rejected because: PR reviewers (human and agent) lose visibility into what actually changed in the rendered output for a given source change. Fresh checkouts can't be inspected without first running the build. `git log -- platform-integrations/...` becomes useless. The PR-review story is the dominant cost. - -### Option D — Author a build tool in Go or Rust - -A standalone static binary instead of Python+Jinja2. - -Rejected because: the build only runs in the developer/CI loop, never on user machines (end users continue to use `install.sh` against the committed generated tree). The project is already Python-via-`uv`, so the Python+Jinja2 path adds zero new toolchain. A Go/Rust binary would add a build/release pipeline for the tool itself with no compensating benefit at the user-facing layer. - -## Consequences - -### Positive - -- A single canonical edit location for maintainers and agents. The auto-memory note `evolve-lite has three variants` becomes obsolete. -- Drift between platform copies is mechanically prevented (CI fails when committed output ≠ fresh render). -- Adding a new platform integration becomes a config-file addition rather than a copy-paste, lowering future maintenance cost. -- Synthesis of the three drifted versions of `save_entities.py` (and similar files) is captured once, in the canonical source, with documented rationale. -- Bob's Windows incompatibility is fixed in passing. - -### Negative - -- The repository now has *both* hand-edited source (`plugin-source/`) and generated output (`platform-integrations/`) in tree. Contributors must be aware of the distinction. Mitigated by `.generated` marker files in each generated subtree and `# DO NOT EDIT` headers on rendered files where comment syntax allows. -- The build step adds a small but real friction to every change. Mitigated by the `just compile-plugins` recipe and the pre-commit hook that runs it automatically. -- Rendered files are no longer plain Python (they're rendered from `.j2` templates), so IDE tooling on the generated copy is "view-only". Maintainers edit the canonical source where Python files remain valid Python (per the shim-module override pattern); the `.j2` files are mostly markdown and don't lose linting. -- Bob's user-facing slash-command surface may change (`/evolve-lite:learn` → `/evolve-lite-learn`) if Bob's `name:` frontmatter rejects colons. Verified during implementation; documented either way. - -### Migration - -Single PR, multi-commit, per-commit CI green. Stages: (0) this ADR; (1) introduce build pipeline rendering byte-identically to current `platform-integrations/`; (2) synthesize drifted files; (3) rename Bob colon paths; (4) decouple `custom_modes.yaml` from skill enumeration. Each stage is a commit reviewable in isolation and revertable on its own. From 3181274003324b2a5ca5a804f57128fe0a3385bd Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:32:21 -0700 Subject: [PATCH 03/33] feat(build): introduce plugin-source/ and the render-equality build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the canonical source tree (plugin-source/) and the build pipeline that renders it into platform-integrations/. The first managed slice is the four identical lib/*.py helpers shared by claude and claw-code; the byte-identical render produces no diff vs the previously committed copies. What's wired: - plugin-source/MANIFEST.toml declares platforms (claude, claw-code, codex, bob) and the per-file render targets. Verbatim entries only for now; Jinja2 templating and per-platform overlays land in subsequent commits. - scripts/build_plugins.py renders the manifest and detects drift. Stdlib only (tomllib, filecmp, shutil); no new project deps. - justfile gains compile-plugins and check-plugins-rendered recipes. - Pre-commit gains a plugins-rendered hook scoped to plugin-source/, platform-integrations/, and scripts/build_plugins.py. - CI gains a check-plugins-rendered job. - tests/platform_integrations/test_build_pipeline.py covers manifest loading, full-render output, and drift detection (positive and negative cases). Codex and bob declare plugin_root entries but no managed files yet — those land when those platforms' content is migrated in later commits. The existing install.sh continues to do the runtime lib copy for them in the meantime. Refs #219 --- .github/workflows/check-code.yaml | 20 + .pre-commit-config.yaml | 12 + justfile | 9 + plugin-source/MANIFEST.toml | 43 ++ plugin-source/README.md | 29 ++ plugin-source/lib/__init__.py | 0 plugin-source/lib/audit.py | 33 ++ plugin-source/lib/config.py | 472 ++++++++++++++++++ plugin-source/lib/entity_io.py | 298 +++++++++++ scripts/build_plugins.py | 147 ++++++ .../test_build_pipeline.py | 115 +++++ 11 files changed, 1178 insertions(+) create mode 100644 plugin-source/MANIFEST.toml create mode 100644 plugin-source/README.md create mode 100644 plugin-source/lib/__init__.py create mode 100644 plugin-source/lib/audit.py create mode 100644 plugin-source/lib/config.py create mode 100644 plugin-source/lib/entity_io.py create mode 100644 scripts/build_plugins.py create mode 100644 tests/platform_integrations/test_build_pipeline.py diff --git a/.github/workflows/check-code.yaml b/.github/workflows/check-code.yaml index 94b5f3c1..b28e09cb 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 scripts/build_plugins.py check + ui-tests: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41e5f18a..d81f5bd3 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/, scripts/build_plugins.py, + # or the rendered tree under platform-integrations/ has changed. + - repo: local + hooks: + - id: plugins-rendered + name: plugins-rendered + entry: uv run python scripts/build_plugins.py check + language: system + pass_filenames: false + files: ^(plugin-source/|platform-integrations/|scripts/build_plugins\.py) diff --git a/justfile b/justfile index cbbe8107..4b3f174a 100644 --- a/justfile +++ b/justfile @@ -96,3 +96,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 scripts/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 scripts/build_plugins.py check diff --git a/plugin-source/MANIFEST.toml b/plugin-source/MANIFEST.toml new file mode 100644 index 00000000..f4b7f352 --- /dev/null +++ b/plugin-source/MANIFEST.toml @@ -0,0 +1,43 @@ +# Plugin source manifest — canonical source-of-truth for platform-integrations/. +# +# Each platform declares its plugin_root under platform-integrations/. Each file +# entry names a path under plugin-source/, the per-platform target path, and the +# list of platforms that receive it. The build script (scripts/build_plugins.py) +# walks this manifest and emits the platform-integrations/ tree. +# +# This file is hand-edited; everything under platform-integrations/ that +# corresponds to a manifest entry is generated and should not be hand-edited. +# The CI gate (`just check-plugins-rendered`) enforces this invariant. + +[platforms.claude] +plugin_root = "platform-integrations/claude/plugins/evolve-lite" + +[platforms.claw-code] +plugin_root = "platform-integrations/claw-code/plugins/evolve-lite" + +[platforms.codex] +plugin_root = "platform-integrations/codex/plugins/evolve-lite" + +[platforms.bob] +plugin_root = "platform-integrations/bob/evolve-lite" + +# Verbatim copies — file content is identical across every listed platform. +[[files]] +source = "lib/__init__.py" +target = "lib/__init__.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "lib/audit.py" +target = "lib/audit.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "lib/config.py" +target = "lib/config.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "lib/entity_io.py" +target = "lib/entity_io.py" +platforms = ["claude", "claw-code"] 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/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..8efdb444 --- /dev/null +++ b/plugin-source/lib/config.py @@ -0,0 +1,472 @@ +"""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.""" + 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: ignoring repo entry {name!r} — invalid 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: + 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(): + 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 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/scripts/build_plugins.py b/scripts/build_plugins.py new file mode 100644 index 00000000..d50dbfe8 --- /dev/null +++ b/scripts/build_plugins.py @@ -0,0 +1,147 @@ +#!/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/MANIFEST.toml, resolves each file entry +to its per-platform target path, and emits the rendered tree under +platform-integrations/. + +The current implementation handles verbatim file copies. Jinja2 templating +and per-platform overlay logic land in subsequent commits. + +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 shutil +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGIN_SOURCE_DIR = REPO_ROOT / "plugin-source" +MANIFEST_PATH = PLUGIN_SOURCE_DIR / "MANIFEST.toml" + + +@dataclass(frozen=True) +class FileEntry: + source: Path + target_rel: Path + platforms: tuple[str, ...] + + +@dataclass(frozen=True) +class Manifest: + platform_roots: dict[str, Path] + files: tuple[FileEntry, ...] + + +def load_manifest() -> Manifest: + raw = tomllib.loads(MANIFEST_PATH.read_text()) + platform_roots = {name: REPO_ROOT / cfg["plugin_root"] for name, cfg in raw["platforms"].items()} + files = tuple( + FileEntry( + source=PLUGIN_SOURCE_DIR / entry["source"], + target_rel=Path(entry["target"]), + platforms=tuple(entry["platforms"]), + ) + for entry in raw.get("files", []) + ) + for entry in files: + if not entry.source.is_file(): + raise FileNotFoundError(f"manifest references missing source: {entry.source}") + for platform in entry.platforms: + if platform not in platform_roots: + raise ValueError(f"manifest entry {entry.source} targets unknown platform '{platform}'") + return Manifest(platform_roots=platform_roots, files=files) + + +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 the manifest is + appended. For an in-place build, pass REPO_ROOT. + + Returns the list of paths written, relative to out_root. + """ + manifest = load_manifest() + written: list[Path] = [] + for entry in manifest.files: + for platform in entry.platforms: + plugin_root_abs = manifest.platform_roots[platform] + plugin_root_rel = plugin_root_abs.relative_to(REPO_ROOT) + target = out_root / plugin_root_rel / entry.target_rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(entry.source, target) + written.append(plugin_root_rel / entry.target_rel) + return written + + +def check_drift() -> int: + """Compare committed managed files against fresh-rendered content. + + Returns 0 if every managed file matches its source, 1 otherwise. + """ + manifest = load_manifest() + drifts: list[tuple[Path, Path]] = [] + missing: list[Path] = [] + for entry in manifest.files: + for platform in entry.platforms: + plugin_root = manifest.platform_roots[platform] + committed = plugin_root / entry.target_rel + if not committed.is_file(): + missing.append(committed) + continue + if not filecmp.cmp(entry.source, committed, shallow=False): + drifts.append((entry.source, committed)) + if missing or drifts: + 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, + ) + 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/tests/platform_integrations/test_build_pipeline.py b/tests/platform_integrations/test_build_pipeline.py new file mode 100644 index 00000000..92b9f2b1 --- /dev/null +++ b/tests/platform_integrations/test_build_pipeline.py @@ -0,0 +1,115 @@ +"""Tests for scripts/build_plugins.py — the plugin source compilation pipeline. + +These tests exercise the build pipeline end-to-end: render plugin-source/ into a +temp tree, verify each manifested file lands at its declared per-platform path, +and confirm the drift detector fires when the committed output is perturbed. + +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 / "scripts" / "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.mark.platform_integrations +@pytest.mark.unit +class TestManifest: + def test_manifest_loads_without_error(self, build_module): + manifest = build_module.load_manifest() + assert manifest.platform_roots, "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_manifest_target_platform_is_declared(self, build_module): + manifest = build_module.load_manifest() + declared = set(manifest.platform_roots) + 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 TestRender: + def test_render_into_temp_dir_matches_source(self, tmp_path, build_module): + """Rendering into a fresh dir should produce an exact copy of every source file.""" + written = build_module.render_to(tmp_path) + assert written, "render produced no output" + manifest = build_module.load_manifest() + for entry in manifest.files: + for platform in entry.platforms: + plugin_root_rel = manifest.platform_roots[platform].relative_to(REPO_ROOT) + rendered = tmp_path / plugin_root_rel / entry.target_rel + assert rendered.is_file(), f"render did not emit {rendered}" + assert filecmp.cmp(entry.source, rendered, shallow=False), f"rendered file {rendered} differs from source {entry.source}" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestCheckDrift: + def test_check_passes_on_clean_committed_tree(self, build_module, capsys): + """The committed platform-integrations/ should match plugin-source/ at HEAD.""" + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 0, ( + f"check_drift returned {rc} on a clean tree. stderr:\n{captured.err}\n" + f"This means committed platform-integrations/ has drifted from plugin-source/. " + f"Run `just compile-plugins` and commit the result." + ) + + def test_check_fails_when_committed_file_is_perturbed(self, tmp_path, build_module, monkeypatch, capsys): + """When a committed managed file has been edited, drift detection must fire. + + We simulate this by pointing the build script at a temp REPO_ROOT whose + plugin-source/ matches the real one but whose platform-integrations/ has + a perturbed copy of one managed file. + """ + manifest = build_module.load_manifest() + first_entry = manifest.files[0] + first_platform = first_entry.platforms[0] + plugin_root_rel = manifest.platform_roots[first_platform].relative_to(REPO_ROOT) + + fake_root = tmp_path / "fake_repo" + fake_plugin_source = fake_root / "plugin-source" + shutil.copytree(REPO_ROOT / "plugin-source", fake_plugin_source) + + committed = fake_root / plugin_root_rel / first_entry.target_rel + committed.parent.mkdir(parents=True, exist_ok=True) + committed.write_bytes(first_entry.source.read_bytes() + b"\n# perturbation\n") + + monkeypatch.setattr(build_module, "REPO_ROOT", fake_root) + monkeypatch.setattr(build_module, "PLUGIN_SOURCE_DIR", fake_plugin_source) + monkeypatch.setattr(build_module, "MANIFEST_PATH", fake_plugin_source / "MANIFEST.toml") + + rc = build_module.check_drift() + captured = capsys.readouterr() + assert rc == 1, "check_drift should return 1 when a managed file is perturbed" + assert "drift:" in captured.err, "drift message should be printed to stderr" From 9b5e0aa19581566029aad96ffcd406f57bb80890 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:00:11 -0700 Subject: [PATCH 04/33] feat(build): migrate identical claude/claw-code skill scripts to plugin-source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the six skill scripts that are byte-identical between claude and claw-code today into plugin-source/skills//scripts/. The render remains byte-identical to committed platform-integrations/, so this is a pure source relocation — no behavior change. Migrated: - learn/scripts/save_entities.py - publish/scripts/publish.py - subscribe/scripts/subscribe.py - unsubscribe/scripts/unsubscribe.py - sync/scripts/sync.py - save-trajectory/scripts/save_trajectory.py Not yet migrated (left for the Jinja2 commit): - recall/scripts/retrieve_entities.py — varies across all four platforms. - learn/scripts/on_stop.py and on_stop.sh — claude-only hooks. - save-trajectory/scripts/on_stop.py — claude-only hook. - All SKILL.md files — diverge across platforms. - codex and bob copies of these scripts — diverge from claude/claw-code due to runtime-environment differences (lib path discovery, hook contracts). Refs #219 --- plugin-source/MANIFEST.toml | 34 +++ .../skills/learn/scripts/save_entities.py | 106 ++++++++ .../skills/publish/scripts/publish.py | 168 ++++++++++++ .../scripts/save_trajectory.py | 144 ++++++++++ .../skills/subscribe/scripts/subscribe.py | 154 +++++++++++ plugin-source/skills/sync/scripts/sync.py | 257 ++++++++++++++++++ .../skills/unsubscribe/scripts/unsubscribe.py | 101 +++++++ 7 files changed, 964 insertions(+) create mode 100644 plugin-source/skills/learn/scripts/save_entities.py create mode 100755 plugin-source/skills/publish/scripts/publish.py create mode 100755 plugin-source/skills/save-trajectory/scripts/save_trajectory.py create mode 100755 plugin-source/skills/subscribe/scripts/subscribe.py create mode 100755 plugin-source/skills/sync/scripts/sync.py create mode 100755 plugin-source/skills/unsubscribe/scripts/unsubscribe.py diff --git a/plugin-source/MANIFEST.toml b/plugin-source/MANIFEST.toml index f4b7f352..1f5d5149 100644 --- a/plugin-source/MANIFEST.toml +++ b/plugin-source/MANIFEST.toml @@ -41,3 +41,37 @@ platforms = ["claude", "claw-code"] source = "lib/entity_io.py" target = "lib/entity_io.py" platforms = ["claude", "claw-code"] + +# Skill scripts that are byte-identical across claude and claw-code today. +# (codex and bob have their own variants of these scripts due to runtime +# differences; those collapse into this manifest as Jinja2 templating lands.) + +[[files]] +source = "skills/learn/scripts/save_entities.py" +target = "skills/learn/scripts/save_entities.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/publish/scripts/publish.py" +target = "skills/publish/scripts/publish.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/subscribe/scripts/subscribe.py" +target = "skills/subscribe/scripts/subscribe.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/unsubscribe/scripts/unsubscribe.py" +target = "skills/unsubscribe/scripts/unsubscribe.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/sync/scripts/sync.py" +target = "skills/sync/scripts/sync.py" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/save-trajectory/scripts/save_trajectory.py" +target = "skills/save-trajectory/scripts/save_trajectory.py" +platforms = ["claude", "claw-code"] diff --git a/plugin-source/skills/learn/scripts/save_entities.py b/plugin-source/skills/learn/scripts/save_entities.py new file mode 100644 index 00000000..ef64fd45 --- /dev/null +++ b/plugin-source/skills/learn/scripts/save_entities.py @@ -0,0 +1,106 @@ +#!/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 + +# 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_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() + + # Read entities from stdin + 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") + + # Find or create entities directory + 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}") + + # 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") + if not content: + log(f"Skipping entity without content: {entity}") + continue + if normalize(content) in existing_contents: + 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" + + 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/publish/scripts/publish.py b/plugin-source/skills/publish/scripts/publish.py new file mode 100755 index 00000000..1c8d2ecb --- /dev/null +++ b/plugin-source/skills/publish/scripts/publish.py @@ -0,0 +1,168 @@ +#!/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``. +""" + +import argparse +import datetime +import os +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 audit import append as audit_append # 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) + 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: + 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="Name of the write-scope repo to publish to (optional if exactly one is configured)", + ) + args = parser.parse_args() + + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + + # Validate entity name: must be a plain filename with no path components + if len(Path(args.entity).parts) != 1 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) + + cfg = load_config(str(evolve_dir.resolve().parent)) + target, err = _select_target_repo(cfg, args.repo) + if err is not None: + print(f"Error: {err}", file=sys.stderr) + sys.exit(1) + + identity = cfg.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: + 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 + + # 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"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) + + content = entity_to_markdown(entity) + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=dest_path.parent, + prefix=f".{args.entity}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_file.write(content) + temp_file.flush() + os.fsync(temp_file.fileno()) + tmp_path = Path(temp_file.name) + + tmp_path.replace(dest_path) + src_path.unlink() + finally: + if tmp_path is not None and tmp_path.exists(): + tmp_path.unlink() + + try: + audit_append( + project_root=str(evolve_dir.resolve().parent), + 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) + + print(f"Published: {args.entity} -> {dest_path} (repo: {target['name']})") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/save-trajectory/scripts/save_trajectory.py b/plugin-source/skills/save-trajectory/scripts/save_trajectory.py new file mode 100755 index 00000000..f34571eb --- /dev/null +++ b/plugin-source/skills/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/subscribe/scripts/subscribe.py b/plugin-source/skills/subscribe/scripts/subscribe.py new file mode 100755 index 00000000..897eaf49 --- /dev/null +++ b/plugin-source/skills/subscribe/scripts/subscribe.py @@ -0,0 +1,154 @@ +#!/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: + + 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 + +# Add lib to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +from config import ( # noqa: E402 + VALID_SCOPES, + is_valid_repo_name, + load_config, + normalize_repos, + 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("--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") + 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) + + 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: + 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: directory already exists: {dest}\nRun /evolve-lite:unsubscribe to remove it before re-subscribing.", + 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. + 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 as exc: + repos.pop() + shutil.rmtree(dest, ignore_errors=True) + print(f"Error: failed to record subscription — clone removed: {exc}", file=sys.stderr) + sys.exit(1) + + 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: + 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) + + print(f"Subscribed to '{args.name}' (scope={args.scope}) from {args.remote}") + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/sync/scripts/sync.py b/plugin-source/skills/sync/scripts/sync.py new file mode 100755 index 00000000..8038e100 --- /dev/null +++ b/plugin-source/skills/sync/scripts/sync.py @@ -0,0 +1,257 @@ +#!/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 pull --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 + +# 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 +from audit import append as audit_append # 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], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + 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 = _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}``. + + 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 = _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", + ) + 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 + 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="Path to config file (default: evolve.config.yaml in project root)", + ) + 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")) + project_root = str(evolve_dir.parent) if "EVOLVE_DIR" in os.environ else "." + + 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) + + # 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) + + if not repos: + if not args.quiet: + print("No subscriptions configured. Add one with /evolve-lite:subscribe 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 repo in repos: + name = repo.get("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 + + repo_path = evolve_dir / "entities" / "subscribed" / name + + 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)") + 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 + else: + head_before = _head_hash(repo_path) + + 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: + 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}" + 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) + total_delta[name] = delta + + has_changes = any(v > 0 for v in delta.values()) + if has_changes: + any_changes = True + + delta_str = f"+{delta['added']} added, {delta['updated']} updated, {delta['removed']} removed" + summaries.append(f"{name} [{scope}] ({delta_str})") + + # Audit + audit_append( + project_root=project_root, + 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) + + +if __name__ == "__main__": + main() diff --git a/plugin-source/skills/unsubscribe/scripts/unsubscribe.py b/plugin-source/skills/unsubscribe/scripts/unsubscribe.py new file mode 100755 index 00000000..4fc9e697 --- /dev/null +++ b/plugin-source/skills/unsubscribe/scripts/unsubscribe.py @@ -0,0 +1,101 @@ +#!/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. +""" + +import argparse +import json +import os +import shutil +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 ( # noqa: E402 + is_valid_repo_name, + load_config, + normalize_repos, + save_config, + set_repos, +) +from audit import append as audit_append # noqa: E402 + + +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() + + project_root = "." + evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve")) + + cfg = load_config(project_root) + repos = normalize_repos(cfg) + + if args.list: + print(json.dumps(repos, indent=2)) + 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: + 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() From 4c0b646ac81f0223cce1084a6292b8a170e93266 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:10:48 -0700 Subject: [PATCH 05/33] feat(build): add Jinja2 templating to the plugin source pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Jinja2 rendering for source files ending in .j2. Each platform's [platforms.] table in MANIFEST.toml now accepts arbitrary keys beyond plugin_root; everything else is forwarded to the template as a context variable, alongside `platform = ""`. Verbatim copy remains the default for non-.j2 sources. Demonstrates the mechanism on skills/learn/SKILL.md, the first templated file. Two real per-platform variations are now expressed in one shared .j2 template: - forked_context (bool) — claude wraps learn in a forked execution model and needs a "Step 0: Load the Conversation" section that reads the stop-hook transcript; claw-code does not. The bool gates a {% if %} block plus a small inline phrasing tweak in Step 1. - save_entities_invocation (str) — claude invokes the save script via ${CLAUDE_PLUGIN_ROOT}/...; claw-code does a config-home lookup dance. The string is substituted in three places (Method 1/2/3 examples). Render produces byte-identical output to the previously committed SKILL.md files for both claude and claw-code; drift gate stays green. Build-pipeline tests grow a TestJinjaTemplating class that asserts a shared .j2 source produces platform-specific output; existing tests updated for the renamed Manifest.platforms attribute (was platform_roots) and split into "every target rendered" plus "verbatim files match source byte-for-byte". This is commit 3a of the migration plan; commit 3b will sweep the remaining drifted SKILL.md files and the per-platform script variation (retrieve_entities.py, codex/bob save_entities.py, on_stop.* hooks). Refs #219 --- plugin-source/MANIFEST.toml | 26 +++- plugin-source/skills/learn/SKILL.md.j2 | 124 ++++++++++++++++++ scripts/build_plugins.py | 74 +++++++++-- .../test_build_pipeline.py | 66 ++++++++-- 4 files changed, 259 insertions(+), 31 deletions(-) create mode 100644 plugin-source/skills/learn/SKILL.md.j2 diff --git a/plugin-source/MANIFEST.toml b/plugin-source/MANIFEST.toml index 1f5d5149..2cc12e69 100644 --- a/plugin-source/MANIFEST.toml +++ b/plugin-source/MANIFEST.toml @@ -1,9 +1,15 @@ # Plugin source manifest — canonical source-of-truth for platform-integrations/. # -# Each platform declares its plugin_root under platform-integrations/. Each file -# entry names a path under plugin-source/, the per-platform target path, and the -# list of platforms that receive it. The build script (scripts/build_plugins.py) -# walks this manifest and emits the platform-integrations/ tree. +# Each [platforms.] table declares one platform. plugin_root is the +# directory under platform-integrations/ that the renderer writes into; every +# other key in the table is passed to Jinja2 as a per-platform context variable +# when rendering .j2 source files. The Jinja2 environment also receives +# `platform = ""` so templates can branch on the active platform. +# +# Each [[files]] entry names a path under plugin-source/, the per-platform +# target path under plugin_root, and the list of platforms that receive it. +# Sources ending in `.j2` are rendered through Jinja2; everything else is +# copied verbatim. # # This file is hand-edited; everything under platform-integrations/ that # corresponds to a manifest entry is generated and should not be hand-edited. @@ -11,9 +17,13 @@ [platforms.claude] plugin_root = "platform-integrations/claude/plugins/evolve-lite" +forked_context = true +save_entities_invocation = "python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/save_entities.py" [platforms.claw-code] plugin_root = "platform-integrations/claw-code/plugins/evolve-lite" +forked_context = false +save_entities_invocation = "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\"'" [platforms.codex] plugin_root = "platform-integrations/codex/plugins/evolve-lite" @@ -75,3 +85,11 @@ platforms = ["claude", "claw-code"] source = "skills/save-trajectory/scripts/save_trajectory.py" target = "skills/save-trajectory/scripts/save_trajectory.py" platforms = ["claude", "claw-code"] + +# Templated SKILL.md prose. Branches on `forked_context` (claude has Step 0 to +# load a forked-context transcript; claw-code does not) and substitutes +# `save_entities_invocation` (different shell wrappers per platform). +[[files]] +source = "skills/learn/SKILL.md.j2" +target = "skills/learn/SKILL.md" +platforms = ["claude", "claw-code"] diff --git a/plugin-source/skills/learn/SKILL.md.j2 b/plugin-source/skills/learn/SKILL.md.j2 new file mode 100644 index 00000000..4fe8751c --- /dev/null +++ b/plugin-source/skills/learn/SKILL.md.j2 @@ -0,0 +1,124 @@ +--- +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 + +{% if forked_context -%} +### 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 the literal marker `The session transcript is at: ` — find that exact phrase, take everything after the colon, strip surrounding whitespace and quotes, and use the result as `transcript_path`. Then read it: + +```bash +cat +``` + +**You must read this file to analyze the conversation** — the forked context has no other access to it. + +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. + +If no transcript path was provided, fall back to `.evolve/trajectories/`, which may contain either format: + +- **`trajectory_*.json`** — a single JSON object with `messages: [{role, content}, …]`. Prefer the most recent one; parse with `json.load`. +- **`claude-transcript_*.jsonl`** — raw Claude JSONL (same format as the primary `transcript_path`). Parse line-by-line. + +If no transcript is available at all, output zero entities. + +{% endif -%} +### Step 1: Analyze the Conversation + +Review the conversation{% if forked_context %} (loaded from the transcript){% endif %} 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" + } + ] +}' | {{ save_entities_invocation }} +``` + +#### Method 2: From File + +```bash +cat entities.json | {{ save_entities_invocation }} +``` + +#### Method 3: Interactive + +```bash +{{ save_entities_invocation }} +# 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/scripts/build_plugins.py b/scripts/build_plugins.py index d50dbfe8..ec554fbd 100644 --- a/scripts/build_plugins.py +++ b/scripts/build_plugins.py @@ -6,8 +6,8 @@ to its per-platform target path, and emits the rendered tree under platform-integrations/. -The current implementation handles verbatim file copies. Jinja2 templating -and per-platform overlay logic land in subsequent commits. +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/. @@ -25,11 +25,23 @@ import tomllib from dataclasses import dataclass from pathlib import Path +from typing import Any + +from jinja2 import Environment, FileSystemLoader, StrictUndefined REPO_ROOT = Path(__file__).resolve().parent.parent PLUGIN_SOURCE_DIR = REPO_ROOT / "plugin-source" MANIFEST_PATH = PLUGIN_SOURCE_DIR / "MANIFEST.toml" +# Reserved manifest keys under [platforms.]; everything else becomes Jinja2 context. +_RESERVED_PLATFORM_KEYS = frozenset({"plugin_root"}) + + +@dataclass(frozen=True) +class PlatformConfig: + plugin_root: Path + context: dict[str, Any] + @dataclass(frozen=True) class FileEntry: @@ -40,13 +52,17 @@ class FileEntry: @dataclass(frozen=True) class Manifest: - platform_roots: dict[str, Path] + platforms: dict[str, PlatformConfig] files: tuple[FileEntry, ...] def load_manifest() -> Manifest: raw = tomllib.loads(MANIFEST_PATH.read_text()) - platform_roots = {name: REPO_ROOT / cfg["plugin_root"] for name, cfg in raw["platforms"].items()} + platforms: dict[str, PlatformConfig] = {} + for name, cfg in raw["platforms"].items(): + plugin_root = REPO_ROOT / cfg["plugin_root"] + context = {key: val for key, val in cfg.items() if key not in _RESERVED_PLATFORM_KEYS} + platforms[name] = PlatformConfig(plugin_root=plugin_root, context=context) files = tuple( FileEntry( source=PLUGIN_SOURCE_DIR / entry["source"], @@ -59,9 +75,29 @@ def load_manifest() -> Manifest: if not entry.source.is_file(): raise FileNotFoundError(f"manifest references missing source: {entry.source}") for platform in entry.platforms: - if platform not in platform_roots: + if platform not in platforms: raise ValueError(f"manifest entry {entry.source} targets unknown platform '{platform}'") - return Manifest(platform_roots=platform_roots, files=files) + 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]: @@ -73,14 +109,19 @@ def render_to(out_root: Path) -> list[Path]: Returns the list of paths written, relative to out_root. """ manifest = load_manifest() + env = _jinja_env() written: list[Path] = [] for entry in manifest.files: for platform in entry.platforms: - plugin_root_abs = manifest.platform_roots[platform] - plugin_root_rel = plugin_root_abs.relative_to(REPO_ROOT) + cfg = manifest.platforms[platform] + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) target = out_root / plugin_root_rel / entry.target_rel target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(entry.source, target) + 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 / entry.target_rel) return written @@ -91,17 +132,24 @@ def check_drift() -> int: Returns 0 if every managed file matches its source, 1 otherwise. """ manifest = load_manifest() + env = _jinja_env() drifts: list[tuple[Path, Path]] = [] missing: list[Path] = [] for entry in manifest.files: for platform in entry.platforms: - plugin_root = manifest.platform_roots[platform] - committed = plugin_root / entry.target_rel + cfg = manifest.platforms[platform] + committed = cfg.plugin_root / entry.target_rel if not committed.is_file(): missing.append(committed) continue - if not filecmp.cmp(entry.source, committed, shallow=False): - drifts.append((entry.source, committed)) + 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)) if missing or drifts: for path in missing: print(f"missing managed file: {path.relative_to(REPO_ROOT)}", file=sys.stderr) diff --git a/tests/platform_integrations/test_build_pipeline.py b/tests/platform_integrations/test_build_pipeline.py index 92b9f2b1..de8fdda8 100644 --- a/tests/platform_integrations/test_build_pipeline.py +++ b/tests/platform_integrations/test_build_pipeline.py @@ -40,7 +40,7 @@ def build_module(): class TestManifest: def test_manifest_loads_without_error(self, build_module): manifest = build_module.load_manifest() - assert manifest.platform_roots, "manifest declares no platforms" + assert manifest.platforms, "manifest declares no platforms" assert manifest.files, "manifest declares no files" def test_every_manifest_source_exists(self, build_module): @@ -50,7 +50,7 @@ def test_every_manifest_source_exists(self, build_module): def test_every_manifest_target_platform_is_declared(self, build_module): manifest = build_module.load_manifest() - declared = set(manifest.platform_roots) + 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}" @@ -59,17 +59,30 @@ def test_every_manifest_target_platform_is_declared(self, build_module): @pytest.mark.platform_integrations @pytest.mark.unit class TestRender: - def test_render_into_temp_dir_matches_source(self, tmp_path, build_module): - """Rendering into a fresh dir should produce an exact copy of every source file.""" + def test_render_into_temp_dir_produces_every_manifest_target(self, tmp_path, build_module): + """Rendering into a fresh dir should write every declared (file × platform) target.""" written = build_module.render_to(tmp_path) assert written, "render produced no output" manifest = build_module.load_manifest() for entry in manifest.files: for platform in entry.platforms: - plugin_root_rel = manifest.platform_roots[platform].relative_to(REPO_ROOT) + cfg = manifest.platforms[platform] + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) rendered = tmp_path / plugin_root_rel / entry.target_rel assert rendered.is_file(), f"render did not emit {rendered}" - assert filecmp.cmp(entry.source, rendered, shallow=False), f"rendered file {rendered} differs from source {entry.source}" + + def test_verbatim_files_match_source_byte_for_byte(self, tmp_path, build_module): + """Non-template (.py, .md, etc) files should be copied byte-for-byte.""" + build_module.render_to(tmp_path) + 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] + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) + rendered = tmp_path / plugin_root_rel / entry.target_rel + assert filecmp.cmp(entry.source, rendered, shallow=False), f"verbatim file {rendered} differs from source {entry.source}" @pytest.mark.platform_integrations @@ -88,22 +101,23 @@ def test_check_passes_on_clean_committed_tree(self, build_module, capsys): def test_check_fails_when_committed_file_is_perturbed(self, tmp_path, build_module, monkeypatch, capsys): """When a committed managed file has been edited, drift detection must fire. - We simulate this by pointing the build script at a temp REPO_ROOT whose - plugin-source/ matches the real one but whose platform-integrations/ has - a perturbed copy of one managed file. + Points the build script at a temp REPO_ROOT whose plugin-source/ matches + the real one but whose platform-integrations/ has a perturbed copy of one + managed file. Picks a verbatim (non-template) file so we can compare bytes + directly without re-rendering. """ manifest = build_module.load_manifest() - first_entry = manifest.files[0] - first_platform = first_entry.platforms[0] - plugin_root_rel = manifest.platform_roots[first_platform].relative_to(REPO_ROOT) + verbatim_entry = next(e for e in manifest.files if not build_module._is_template(e.source)) + first_platform = verbatim_entry.platforms[0] + plugin_root_rel = manifest.platforms[first_platform].plugin_root.relative_to(REPO_ROOT) fake_root = tmp_path / "fake_repo" fake_plugin_source = fake_root / "plugin-source" shutil.copytree(REPO_ROOT / "plugin-source", fake_plugin_source) - committed = fake_root / plugin_root_rel / first_entry.target_rel + committed = fake_root / plugin_root_rel / verbatim_entry.target_rel committed.parent.mkdir(parents=True, exist_ok=True) - committed.write_bytes(first_entry.source.read_bytes() + b"\n# perturbation\n") + committed.write_bytes(verbatim_entry.source.read_bytes() + b"\n# perturbation\n") monkeypatch.setattr(build_module, "REPO_ROOT", fake_root) monkeypatch.setattr(build_module, "PLUGIN_SOURCE_DIR", fake_plugin_source) @@ -113,3 +127,27 @@ def test_check_fails_when_committed_file_is_perturbed(self, tmp_path, build_modu captured = capsys.readouterr() assert rc == 1, "check_drift should return 1 when a managed file is perturbed" assert "drift:" in captured.err, "drift message should be printed to stderr" + + +@pytest.mark.platform_integrations +@pytest.mark.unit +class TestJinjaTemplating: + def test_template_renders_with_per_platform_context(self, tmp_path, build_module): + """A .j2 source rendered for two platforms should produce platform-specific output.""" + manifest = build_module.load_manifest() + template_entry = next((e for e in manifest.files if build_module._is_template(e.source)), None) + if template_entry is None or len(template_entry.platforms) < 2: + pytest.skip("manifest has no .j2 file shared between two platforms yet") + + build_module.render_to(tmp_path) + outputs = [] + for platform in template_entry.platforms: + cfg = manifest.platforms[platform] + plugin_root_rel = cfg.plugin_root.relative_to(REPO_ROOT) + rendered = tmp_path / plugin_root_rel / template_entry.target_rel + outputs.append(rendered.read_bytes()) + + assert any(a != b for a, b in zip(outputs, outputs[1:])), ( + "expected at least one pair of platform renderings to differ for a templated source; " + "if every platform produces the same bytes, the .j2 file does not actually use its context" + ) From 5ea1275d9395460da74a76bcf242e21d5705627f Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:21:50 -0700 Subject: [PATCH 06/33] feat(build): template all claude/claw-code SKILL.md prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the remaining seven SKILL.md files (recall, publish, subscribe, unsubscribe, sync, save-trajectory, save) into shared .j2 templates that render byte-identically (modulo one trivial whitespace fix, see below) to the previously committed claude and claw-code copies. The dominant per-platform variation across these files is the script invocation snippet — claude expands ${CLAUDE_PLUGIN_ROOT} via its plugin runtime; claw-code does a config-home discovery dance wrapped in sh -lc '...'. Rather than store the long claw-code shell command as a manifest variable for each skill, this introduces a shared Jinja2 macro (plugin-source/_macros.j2 :: invoke(skill, script, args)) that emits the platform-appropriate form. `args` accepts None, a string, or a list — when given a list, claude renders one arg per line with backslash continuation (matches the existing publish/subscribe formatting); claw-code stays single-line because the whole command is inside sh -lc '...'. The remaining variation is captured in two new per-platform manifest keys plus an inline conditional block in recall: - forked_context (bool) — Step 0 of learn loads a forked-context transcript on claude; not relevant on claw-code. - save_example_script_root (str) — placeholder root used in save's example invocations (${CLAUDE_PLUGIN_ROOT}/skills vs ~/.claw/skills). - user_skills_dir (str) — where the save skill writes the new skill (~/.claude/skills vs ~/.claw/skills). - recall's "How It Works" prose differs in step 1-2 wording (claude fires on user prompt submit; claw-code fires on PreToolUse) and references "Claude" vs "the agent" in two places. Inline {% if %}. learn/SKILL.md.j2 (introduced in the previous commit) is migrated from its bespoke `save_entities_invocation` manifest var to the shared invoke() macro. The save_entities_invocation key is dropped. One incidental cleanup: save/SKILL.md had four trailing spaces on two blank lines inside an embedded python code-block example (legacy of an earlier editor). The .j2 template renders those lines without the trailing whitespace; the committed claude+claw-code copies are updated to match. No semantic change. Codex and bob SKILL.md files are not migrated in this commit — their prose diverges substantially (different audience LLMs, different hook contracts) and they need either deeper conditionals or per-platform overlay files. Those land in commit 3c alongside the script-synthesis work. Refs #219 --- .../plugins/evolve-lite/skills/save/SKILL.md | 4 +- .../plugins/evolve-lite/skills/save/SKILL.md | 4 +- plugin-source/MANIFEST.toml | 48 +- plugin-source/_macros.j2 | 32 ++ plugin-source/skills/learn/SKILL.md.j2 | 7 +- plugin-source/skills/publish/SKILL.md.j2 | 212 ++++++++ plugin-source/skills/recall/SKILL.md.j2 | 56 +++ .../skills/save-trajectory/SKILL.md.j2 | 147 ++++++ plugin-source/skills/save/SKILL.md.j2 | 471 ++++++++++++++++++ plugin-source/skills/subscribe/SKILL.md.j2 | 89 ++++ plugin-source/skills/sync/SKILL.md.j2 | 32 ++ plugin-source/skills/unsubscribe/SKILL.md.j2 | 65 +++ 12 files changed, 1155 insertions(+), 12 deletions(-) create mode 100644 plugin-source/_macros.j2 create mode 100644 plugin-source/skills/publish/SKILL.md.j2 create mode 100644 plugin-source/skills/recall/SKILL.md.j2 create mode 100644 plugin-source/skills/save-trajectory/SKILL.md.j2 create mode 100644 plugin-source/skills/save/SKILL.md.j2 create mode 100644 plugin-source/skills/subscribe/SKILL.md.j2 create mode 100644 plugin-source/skills/sync/SKILL.md.j2 create mode 100644 plugin-source/skills/unsubscribe/SKILL.md.j2 diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/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/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/claw-code/plugins/evolve-lite/skills/save/SKILL.md b/platform-integrations/claw-code/plugins/evolve-lite/skills/save/SKILL.md index b39e5a39..526c9591 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/save/SKILL.md +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/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/plugin-source/MANIFEST.toml b/plugin-source/MANIFEST.toml index 2cc12e69..9f41720f 100644 --- a/plugin-source/MANIFEST.toml +++ b/plugin-source/MANIFEST.toml @@ -18,12 +18,14 @@ [platforms.claude] plugin_root = "platform-integrations/claude/plugins/evolve-lite" forked_context = true -save_entities_invocation = "python3 ${CLAUDE_PLUGIN_ROOT}/skills/learn/scripts/save_entities.py" +user_skills_dir = "~/.claude/skills" +save_example_script_root = "${CLAUDE_PLUGIN_ROOT}/skills" [platforms.claw-code] plugin_root = "platform-integrations/claw-code/plugins/evolve-lite" forked_context = false -save_entities_invocation = "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\"'" +user_skills_dir = "~/.claw/skills" +save_example_script_root = "~/.claw/skills" [platforms.codex] plugin_root = "platform-integrations/codex/plugins/evolve-lite" @@ -86,10 +88,46 @@ source = "skills/save-trajectory/scripts/save_trajectory.py" target = "skills/save-trajectory/scripts/save_trajectory.py" platforms = ["claude", "claw-code"] -# Templated SKILL.md prose. Branches on `forked_context` (claude has Step 0 to -# load a forked-context transcript; claw-code does not) and substitutes -# `save_entities_invocation` (different shell wrappers per platform). +# Templated SKILL.md files. Each .j2 imports plugin-source/_macros.j2 (where +# applicable) and branches on per-platform context vars (forked_context, +# user_skills_dir, save_example_script_root) plus the implicit `platform` name. + [[files]] source = "skills/learn/SKILL.md.j2" target = "skills/learn/SKILL.md" platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/recall/SKILL.md.j2" +target = "skills/recall/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/publish/SKILL.md.j2" +target = "skills/publish/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/subscribe/SKILL.md.j2" +target = "skills/subscribe/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/unsubscribe/SKILL.md.j2" +target = "skills/unsubscribe/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/sync/SKILL.md.j2" +target = "skills/sync/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/save-trajectory/SKILL.md.j2" +target = "skills/save-trajectory/SKILL.md" +platforms = ["claude", "claw-code"] + +[[files]] +source = "skills/save/SKILL.md.j2" +target = "skills/save/SKILL.md" +platforms = ["claude", "claw-code"] diff --git a/plugin-source/_macros.j2 b/plugin-source/_macros.j2 new file mode 100644 index 00000000..f245b748 --- /dev/null +++ b/plugin-source/_macros.j2 @@ -0,0 +1,32 @@ +{# 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); + claw-code stays single-line because the whole command is wrapped + in `sh -lc '...'`. + + Claude expands ${CLAUDE_PLUGIN_ROOT} via its plugin runtime; Claw-code has + no such env var so it walks up from .claw/skills/ to a config-home fallback. +#} +{%- macro invoke(skill, script, args=None) -%} +{%- if platform == "claude" -%} +python3 ${CLAUDE_PLUGIN_ROOT}/skills/{{ 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 -%} +' +{%- endif -%} +{%- endmacro -%} diff --git a/plugin-source/skills/learn/SKILL.md.j2 b/plugin-source/skills/learn/SKILL.md.j2 index 4fe8751c..4213f427 100644 --- a/plugin-source/skills/learn/SKILL.md.j2 +++ b/plugin-source/skills/learn/SKILL.md.j2 @@ -1,3 +1,4 @@ +{%- from "_macros.j2" import invoke with context -%} --- name: learn description: Analyze the current conversation to extract guidelines that correct reasoning chains — reducing wasted steps, preventing errors, and capturing user preferences. @@ -90,19 +91,19 @@ echo '{ "trigger": "Situational context when this applies" } ] -}' | {{ save_entities_invocation }} +}' | {{ invoke("learn", "save_entities.py") }} ``` #### Method 2: From File ```bash -cat entities.json | {{ save_entities_invocation }} +cat entities.json | {{ invoke("learn", "save_entities.py") }} ``` #### Method 3: Interactive ```bash -{{ save_entities_invocation }} +{{ invoke("learn", "save_entities.py") }} # Then paste your JSON and press Ctrl+D ``` diff --git a/plugin-source/skills/publish/SKILL.md.j2 b/plugin-source/skills/publish/SKILL.md.j2 new file mode 100644 index 00000000..9587019c --- /dev/null +++ b/plugin-source/skills/publish/SKILL.md.j2 @@ -0,0 +1,212 @@ +{%- from "_macros.j2" import invoke with context -%} +--- +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 +{{ invoke("publish", "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/plugin-source/skills/recall/SKILL.md.j2 b/plugin-source/skills/recall/SKILL.md.j2 new file mode 100644 index 00000000..38fe07e8 --- /dev/null +++ b/plugin-source/skills/recall/SKILL.md.j2 @@ -0,0 +1,56 @@ +--- +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 {% if platform == "claude" %}Claude{% else %}the agent{% endif %} for relevance filtering. + +## How It Works + +{% if platform == "claude" -%} +1. Hook fires on user prompt submission +2. Script reads prompt from stdin (JSON with `prompt` field) +{%- else -%} +1. The PreToolUse hook fires before each tool call +2. Script reads tool input from stdin (best-effort, ignored beyond logging) +{%- endif %} +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. {% if platform == "claude" %}Claude{% else %}The agent{% endif %} 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/plugin-source/skills/save-trajectory/SKILL.md.j2 b/plugin-source/skills/save-trajectory/SKILL.md.j2 new file mode 100644 index 00000000..ecff9486 --- /dev/null +++ b/plugin-source/skills/save-trajectory/SKILL.md.j2 @@ -0,0 +1,147 @@ +--- +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. + +## 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" +{%- 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/save/SKILL.md.j2 b/plugin-source/skills/save/SKILL.md.j2 new file mode 100644 index 00000000..48171e90 --- /dev/null +++ b/plugin-source/skills/save/SKILL.md.j2 @@ -0,0 +1,471 @@ +--- +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 + +## 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/subscribe/SKILL.md.j2 b/plugin-source/skills/subscribe/SKILL.md.j2 new file mode 100644 index 00000000..4673e252 --- /dev/null +++ b/plugin-source/skills/subscribe/SKILL.md.j2 @@ -0,0 +1,89 @@ +{%- from "_macros.j2" import invoke with context -%} +--- +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 +{{ invoke("subscribe", "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/plugin-source/skills/sync/SKILL.md.j2 b/plugin-source/skills/sync/SKILL.md.j2 new file mode 100644 index 00000000..5f4feb3b --- /dev/null +++ b/plugin-source/skills/sync/SKILL.md.j2 @@ -0,0 +1,32 @@ +{%- from "_macros.j2" import invoke with context -%} +--- +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 +{{ invoke("sync", "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/plugin-source/skills/unsubscribe/SKILL.md.j2 b/plugin-source/skills/unsubscribe/SKILL.md.j2 new file mode 100644 index 00000000..2da8cc4b --- /dev/null +++ b/plugin-source/skills/unsubscribe/SKILL.md.j2 @@ -0,0 +1,65 @@ +{%- from "_macros.j2" import invoke with context -%} +--- +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 +{{ invoke("unsubscribe", "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 +{{ invoke("unsubscribe", "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 +{{ invoke("unsubscribe", "unsubscribe.py", "--name {name} --force") }} +``` + +### Step 5: Confirm + +Tell the user: + +> "Removed '{name}'." From df5fec792b2e1e1e9528bc373b37855bf7e78726 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:25:27 -0700 Subject: [PATCH 07/33] feat(build): add claude-only on_stop hooks as platform overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three files exist only on the claude tree (not on claw-code, codex, or bob): the forked-context stop hooks for `learn` and `save-trajectory`. Bringing them under build management uses the per-platform overlay pattern — manifest entries with `platforms = ["claude"]` and a single source path under plugin-source/. The renderer emits them only into the claude tree; the drift gate enforces byte-identity. Files: learn/scripts/on_stop.py, learn/scripts/on_stop.sh, save-trajectory/scripts/on_stop.py. Mypy now also excludes plugin-source/ (it already excluded platform-integrations/). The two on_stop.py files share a module name, which the existing exclusion handled in the rendered tree but not in the source tree. Notes on what is NOT in this commit: - save_entities.py for codex is *not* synthesized in this commit. Codex's variant ignores incoming owner/visibility values from stdin (see test_codex_sharing.py::test_save_ignores_incoming_owner_and_visibility), while claude/claw-code preserve them if set. That is a deliberate per-platform security stance, not drift, and collapsing it would either change codex behavior or introduce a new behavior-flag knob — worth its own PR with explicit user buy-in. - retrieve_entities.py is also not synthesized here. Beyond the lib-path discovery prelude (which the shim pattern would cover), the bodies legitimately differ across platforms: claude logs env vars and argv for debugging while codex doesn't, codex calls find_entities_dir while claude calls find_recall_entity_dirs, and the output header text varies. Synthesis warrants a focused commit. - Codex and bob SKILL.md files remain hand-edited in platform-integrations/. Their prose is tuned for different audience LLMs and would mostly require Pattern B (per-platform overlay files) rather than Jinja2 conditionals; deferring until the broader migration shape settles. Refs #219 --- plugin-source/MANIFEST.toml | 18 ++++ plugin-source/skills/learn/scripts/on_stop.py | 35 ++++++++ plugin-source/skills/learn/scripts/on_stop.sh | 15 ++++ .../skills/save-trajectory/scripts/on_stop.py | 84 +++++++++++++++++++ pyproject.toml | 1 + 5 files changed, 153 insertions(+) create mode 100644 plugin-source/skills/learn/scripts/on_stop.py create mode 100755 plugin-source/skills/learn/scripts/on_stop.sh create mode 100644 plugin-source/skills/save-trajectory/scripts/on_stop.py diff --git a/plugin-source/MANIFEST.toml b/plugin-source/MANIFEST.toml index 9f41720f..bd5efb1b 100644 --- a/plugin-source/MANIFEST.toml +++ b/plugin-source/MANIFEST.toml @@ -131,3 +131,21 @@ platforms = ["claude", "claw-code"] source = "skills/save/SKILL.md.j2" target = "skills/save/SKILL.md" platforms = ["claude", "claw-code"] + +# Claude-only stop hooks. The forked-context learn skill needs an on_stop +# entrypoint that the Claude harness invokes; non-claude platforms have no +# equivalent hook contract. +[[files]] +source = "skills/learn/scripts/on_stop.py" +target = "skills/learn/scripts/on_stop.py" +platforms = ["claude"] + +[[files]] +source = "skills/learn/scripts/on_stop.sh" +target = "skills/learn/scripts/on_stop.sh" +platforms = ["claude"] + +[[files]] +source = "skills/save-trajectory/scripts/on_stop.py" +target = "skills/save-trajectory/scripts/on_stop.py" +platforms = ["claude"] diff --git a/plugin-source/skills/learn/scripts/on_stop.py b/plugin-source/skills/learn/scripts/on_stop.py new file mode 100644 index 00000000..fc98fb8e --- /dev/null +++ b/plugin-source/skills/learn/scripts/on_stop.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Stop hook that triggers the learn skill to extract guidelines.""" + +import json +import sys + + +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: + reason += f" The session transcript is at: {transcript_path}" + + 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/learn/scripts/on_stop.sh b/plugin-source/skills/learn/scripts/on_stop.sh new file mode 100755 index 00000000..b62b110c --- /dev/null +++ b/plugin-source/skills/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/save-trajectory/scripts/on_stop.py b/plugin-source/skills/save-trajectory/scripts/on_stop.py new file mode 100644 index 00000000..81c3400e --- /dev/null +++ b/plugin-source/skills/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/pyproject.toml b/pyproject.toml index a18bc963..dad13cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,6 +160,7 @@ disallow_untyped_defs = false explicit_package_bases = true exclude = [ "platform-integrations/", + "plugin-source/", "examples/", ] From 07a171cd511aaaae7c6dc5f21c03b9e6250f35f1 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:32:19 -0700 Subject: [PATCH 08/33] refactor(bob): drop colon-prefixed paths for Windows compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bob is the only platform that used colon-prefixed names on disk (.bob/skills/evolve-lite:/, .bob/commands/evolve-lite:.md). Windows treats `:` as a drive separator and rejects it in path components, so the existing layout couldn't be checked out or installed on Windows. Other platforms (claude, codex, claw-code) synthesize the colon namespace from a plugin manifest and don't have the issue. Renames every colon-prefixed source path to a hyphen-prefixed name (evolve-lite-) and updates every reference: bob's custom_modes.yaml prompt, bob's command-file frontmatter, install.sh's BobInstaller glob patterns and status output, and the affected tests in tests/platform_integrations/. User-facing slash-command surface change for Bob users: /evolve-lite:learn → /evolve-lite-learn (etc). Other platforms are unchanged because their plugin manifests still synthesize the colon form for the user-facing namespace. The sole reference to evolve-lite:recall left intact is in install.sh's CodexInstaller post-install message — codex's plugin manifest still produces /evolve-lite:recall as the slash command, so the hyphenated name there would be wrong. Pre-existing test failures unrelated to this rename: - test_bob_sharing.py and test_sync.py and test_codex_sharing.py expect "invalid subscription name" in stdout but sync.py logs "invalid name" to stderr. This drift exists on main (verified before the rename) across all three platforms; same 5 failures before and after. Out of scope here, will need its own commit. The rename FIXES one pre-existing test: test_skill_directory_names.py::test_bob_lite_skills_follow_naming_convention (which now matches the new evolve-lite- prefix expectation). Refs #219 --- ...lve-lite:learn.md => evolve-lite-learn.md} | 2 +- ...lite:publish.md => evolve-lite-publish.md} | 2 +- ...e-lite:recall.md => evolve-lite-recall.md} | 2 +- ...tory.md => evolve-lite-save-trajectory.md} | 2 +- ...:subscribe.md => evolve-lite-subscribe.md} | 2 +- ...volve-lite:sync.md => evolve-lite-sync.md} | 2 +- ...ubscribe.md => evolve-lite-unsubscribe.md} | 2 +- .../bob/evolve-lite/custom_modes.yaml | 42 +++++++++---------- .../SKILL.md | 6 +-- .../scripts/save_entities.py | 0 .../SKILL.md | 6 +-- .../scripts/publish.py | 0 .../SKILL.md | 2 +- .../scripts/retrieve_entities.py | 0 .../SKILL.md | 2 +- .../scripts/save_trajectory.py | 0 .../SKILL.md | 2 +- .../scripts/subscribe.py | 0 .../SKILL.md | 2 +- .../scripts/sync.py | 0 .../SKILL.md | 0 .../scripts/unsubscribe.py | 0 platform-integrations/install.sh | 10 ++--- tests/platform_integrations/conftest.py | 2 +- .../platform_integrations/test_bob_sharing.py | 12 +++--- .../platform_integrations/test_idempotency.py | 8 ++-- .../test_preservation.py | 2 +- .../test_skill_directory_names.py | 36 ++++++++-------- 28 files changed, 73 insertions(+), 73 deletions(-) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:learn.md => evolve-lite-learn.md} (86%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:publish.md => evolve-lite-publish.md} (89%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:recall.md => evolve-lite-recall.md} (86%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:save-trajectory.md => evolve-lite-save-trajectory.md} (83%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:subscribe.md => evolve-lite-subscribe.md} (90%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:sync.md => evolve-lite-sync.md} (89%) rename platform-integrations/bob/evolve-lite/commands/{evolve-lite:unsubscribe.md => evolve-lite-unsubscribe.md} (87%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:learn => evolve-lite-learn}/SKILL.md (95%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:learn => evolve-lite-learn}/scripts/save_entities.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:publish => evolve-lite-publish}/SKILL.md (96%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:publish => evolve-lite-publish}/scripts/publish.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:recall => evolve-lite-recall}/SKILL.md (97%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:recall => evolve-lite-recall}/scripts/retrieve_entities.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:save-trajectory => evolve-lite-save-trajectory}/SKILL.md (98%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:save-trajectory => evolve-lite-save-trajectory}/scripts/save_trajectory.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:subscribe => evolve-lite-subscribe}/SKILL.md (96%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:subscribe => evolve-lite-subscribe}/scripts/subscribe.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:sync => evolve-lite-sync}/SKILL.md (94%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:sync => evolve-lite-sync}/scripts/sync.py (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:unsubscribe => evolve-lite-unsubscribe}/SKILL.md (100%) rename platform-integrations/bob/evolve-lite/skills/{evolve-lite:unsubscribe => evolve-lite-unsubscribe}/scripts/unsubscribe.py (100%) diff --git a/platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md b/platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md similarity index 86% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md index e832b58d..59b46234 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:learn.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-learn.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:learn +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 similarity index 89% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:publish.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-publish.md index 87d603ee..12d09907 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:publish.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-publish.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:publish +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 similarity index 86% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:recall.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-recall.md index e03ce399..58fdccb1 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:recall.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-recall.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:recall +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 similarity index 83% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:save-trajectory.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-save-trajectory.md index fd4c2a0a..6ecb61fb 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:save-trajectory.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-save-trajectory.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:save-trajectory +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 similarity index 90% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:subscribe.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-subscribe.md index 507cbe94..0b660f34 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:subscribe.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-subscribe.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:subscribe +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 similarity index 89% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:sync.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-sync.md index ee7eef8b..c1613533 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:sync.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-sync.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:sync +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 similarity index 87% rename from platform-integrations/bob/evolve-lite/commands/evolve-lite:unsubscribe.md rename to platform-integrations/bob/evolve-lite/commands/evolve-lite-unsubscribe.md index c0c68792..df415e82 100644 --- a/platform-integrations/bob/evolve-lite/commands/evolve-lite:unsubscribe.md +++ b/platform-integrations/bob/evolve-lite/commands/evolve-lite-unsubscribe.md @@ -1,5 +1,5 @@ --- -name: evolve-lite:unsubscribe +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..b24f4749 100644 --- a/platform-integrations/bob/evolve-lite/custom_modes.yaml +++ b/platform-integrations/bob/evolve-lite/custom_modes.yaml @@ -9,18 +9,18 @@ customModes: WORKFLOW (5 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 + - 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. + 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 + 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. + 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 @@ -30,21 +30,21 @@ customModes: - Step 1 must happen before any other tool use (except the SKILL.md reads in Step 0). - 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 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 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): 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) + - 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 @@ -54,9 +54,9 @@ customModes: 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? │ + │ 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. │ @@ -65,9 +65,9 @@ customModes: 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. + - 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 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 similarity index 95% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/SKILL.md index 1580e5c1..2cb96ab3 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/SKILL.md @@ -18,7 +18,7 @@ This skill analyzes the current conversation to extract guidelines that **correc - 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 +- 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 @@ -52,7 +52,7 @@ Principles: ### 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. +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 '{ @@ -65,7 +65,7 @@ echo '{ "trajectory": ".evolve/trajectories/trajectory_2025-01-15T10-30-00.json" } ] -}' | python3 .bob/skills/evolve-lite:learn/scripts/save_entities.py +}' | python3 .bob/skills/evolve-lite-learn/scripts/save_entities.py ``` The script will: diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/save_entities.py similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:learn/scripts/save_entities.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-learn/scripts/save_entities.py 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 96% 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..9a56db5e 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 @@ -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 @@ -21,7 +21,7 @@ and anyone else publishing to the same repo stay in sync. 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." +> "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. @@ -125,7 +125,7 @@ git -C ".evolve/entities/subscribed/{repo}" rebase "origin/{branch}" 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 + {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 diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/scripts/publish.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/scripts/publish.py similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:publish/scripts/publish.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-publish/scripts/publish.py 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 similarity index 97% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md index 6349e719..22698517 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/SKILL.md @@ -27,7 +27,7 @@ Entities can come from multiple sources: - `.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 +Write-scope clones are also where `evolve-lite-publish` lands new guidelines, so your published entities show up here too. ## Usage 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 similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:recall/scripts/retrieve_entities.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-recall/scripts/retrieve_entities.py 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 similarity index 98% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:save-trajectory/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-save-trajectory/SKILL.md index c3c18604..9254a77e 100644 --- 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 @@ -96,7 +96,7 @@ Strip `...` tags and their contents from all 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. ```bash -python3 .bob/skills/evolve-lite:save-trajectory/scripts/save_trajectory.py << 'TRAJECTORY_END' +python3 .bob/skills/evolve-lite-save-trajectory/scripts/save_trajectory.py << 'TRAJECTORY_END' { "model": "", "timestamp": "2025-01-15T10:30:00Z", 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 similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite: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:subscribe/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md similarity index 96% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md index f78d9c23..67373ec8 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/SKILL.md @@ -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/bob/evolve-lite/skills/evolve-lite-subscribe/scripts/subscribe.py similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:subscribe/scripts/subscribe.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-subscribe/scripts/subscribe.py diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md similarity index 94% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md index 1c14d0b2..8ef0f63d 100644 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/SKILL.md +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/SKILL.md @@ -26,7 +26,7 @@ python3 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 +tell them they can add one with `evolve-lite-subscribe`. If there are no changes, explain that everything is already up to date. ## Notes diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/scripts/sync.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:sync/scripts/sync.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py 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 100% 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 diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py similarity index 100% rename from platform-integrations/bob/evolve-lite/skills/evolve-lite:unsubscribe/scripts/unsubscribe.py rename to platform-integrations/bob/evolve-lite/skills/evolve-lite-unsubscribe/scripts/unsubscribe.py diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index cd29f389..55aa0aa4 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -545,11 +545,11 @@ class BobInstaller: 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:*")): + 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")): + for cmd_file in sorted(commands_dir.glob("evolve-lite-*.md")): self.ops.remove_file(cmd_file) 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") @@ -562,14 +562,14 @@ 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 [] + installed_skills = sorted(skills_dir.glob("evolve-lite-*")) 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-lite-* : ✗") 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-lite-*.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 diff --git a/tests/platform_integrations/conftest.py b/tests/platform_integrations/conftest.py index 0e5b4910..4284cc70 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 diff --git a/tests/platform_integrations/test_bob_sharing.py b/tests/platform_integrations/test_bob_sharing.py index f5063a78..c4bcb72c 100644 --- a/tests/platform_integrations/test_bob_sharing.py +++ b/tests/platform_integrations/test_bob_sharing.py @@ -24,12 +24,12 @@ def _load_claude_config_module(): _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): diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index d99f1d04..2976b5ce 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") @@ -143,13 +143,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") diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index 6296d6da..feaaa5f4 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -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_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: From 298413ef8a802c59bf0cbf9cf34bc7345fccca21 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:33:38 -0700 Subject: [PATCH 09/33] refactor(bob): decouple custom_modes.yaml from skill-path enumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops Step 0 of the evolve-lite mode prompt, which used to enumerate specific .bob/skills//SKILL.md paths the agent had to read up front. The relationship between the mode and the skills it depends on was largely a coincidence of the prompt — the mode's job is the workflow contract (recall → work → save-trajectory → learn → complete); the skill registry is whatever Bob's runtime resolves under .bob/skills/. Replaces the path enumeration with a generic instruction to read each skill's SKILL.md before first invocation. Workflow steps still call the relevant skills by name (recall, save-trajectory, learn, plus the optional sharing skills), since the mode's contract is precisely "use these skills in this order." Names, not paths. This finishes the migration plan from #219: 1. ✅ Build pipeline + render-equality gate (commit 1) 2. ✅ Migrate identical claude/claw-code skill scripts (commit 2) 3a. ✅ Jinja2 templating + first per-platform .j2 (commit 3a) 3b. ✅ Sweep remaining claude/claw-code SKILL.md prose (commit 3b) 3c. ✅ Claude-only on_stop overlay files (commit 3c) 4. ✅ Bob colon-prefix rename for Windows compat (commit 4) 5. ✅ Decouple custom_modes.yaml from skill paths (this commit) Followups outside this PR's scope: - Synthesize codex's save_entities.py and the four-platform retrieve_entities.py (real semantic synthesis, deserves focused PR) - Migrate codex/bob SKILL.md content into plugin-source as Pattern B per-platform overlays - Move claw-code's installed-path convention off colons (separate Windows-compat issue, parallel to bob's) - Resolve the pre-existing "invalid subscription name" stdout/stderr drift across claude/codex/bob (5 failing tests on main, untouched by this PR) Refs #219 --- .../bob/evolve-lite/custom_modes.yaml | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/platform-integrations/bob/evolve-lite/custom_modes.yaml b/platform-integrations/bob/evolve-lite/custom_modes.yaml index b24f4749..349e2f26 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: From 354f035501069e1507cfd64c566c8d8cce27bece Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:06:46 -0700 Subject: [PATCH 10/33] fix(sync): surface invalid subscription entries in stdout summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves four pre-existing test failures across claude, codex, and bob sync tests that asserted "invalid subscription name" appeared in stdout when an entry in evolve.config.yaml had an unsafe name (e.g. '../evil', '.', '..'). Root cause: every platform's sync.py used `normalize_repos(cfg)`, which routes through `_coerce_repo` in lib/config.py. _coerce_repo silently filtered invalid entries (after a stray stderr print with a slightly different phrasing — "ignoring repo entry 'X' — invalid name") and returned None. The downstream "skipped — invalid subscription name" branch in each sync.py ran on already-filtered entries, so it never fired. The user saw "No subscriptions configured" and a stderr log with a different message; the tests saw neither in stdout. Fix: - lib/config.py: drop the stderr prints inside _coerce_repo. They were leaky from a library function (callers, not the lib, should decide where to surface a rejection). Add `classify_repo_entry` which returns (repo, rejection) for one raw entry — exactly one is non-None — so callers can iterate raw `cfg["repos"]` and report rejections per their own UX. - claude/claw-code/codex/bob sync.py: replace `normalize_repos(cfg)` with manual iteration over raw entries via classify_repo_entry. Rejection reasons are added to the same `summaries` list that already collects per-repo sync results, so they appear in the user-visible "Synced N repo(s): …" stdout line. Dedup by name is preserved inline. - test_config.py::test_invalid_scope_entries_dropped: replaced its capsys assertion (which depended on the now-removed stderr print) with a direct call to classify_repo_entry that returns the same rejection reason structurally. Test impact: - Fixes test_sync.py::test_skips_invalid_subscription_name - Fixes test_bob_sharing.py::test_skips_invalid_subscription_name - Fixes test_bob_sharing.py::test_rejects_dot_and_double_dot_names - Fixes test_codex_sharing.py::test_sync_skips_invalid_subscription_name - One pre-existing failure remains: test_subscribe_warns_when_audit_write_fails in test_codex_sharing.py. That test asserts subscribe.py warns and continues when the audit log can't be written; the current subscribe.py rolls back and exits 1 (claude and codex both). That's a separate design decision (fail-open UX vs fail-closed security) that deserves its own focused commit. Refs #219 --- .../skills/evolve-lite-sync/scripts/sync.py | 42 ++++++++----- .../claude/plugins/evolve-lite/lib/config.py | 61 ++++++++++++++++--- .../evolve-lite/skills/sync/scripts/sync.py | 31 +++++++--- .../plugins/evolve-lite/lib/config.py | 61 ++++++++++++++++--- .../evolve-lite/skills/sync/scripts/sync.py | 31 +++++++--- .../evolve-lite/skills/sync/scripts/sync.py | 39 +++++++----- plugin-source/lib/config.py | 61 ++++++++++++++++--- plugin-source/skills/sync/scripts/sync.py | 31 +++++++--- tests/platform_integrations/test_config.py | 8 ++- 9 files changed, 283 insertions(+), 82 deletions(-) diff --git a/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py index 0239d587..30bbb199 100755 --- a/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py +++ b/platform-integrations/bob/evolve-lite/skills/evolve-lite-sync/scripts/sync.py @@ -24,7 +24,7 @@ break 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 @@ -107,11 +107,27 @@ def main(): project_root = "." cfg = load_config(project_root) - repos = normalize_repos(cfg) - if not repos: + 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.") + print("No subscriptions configured. Add one with the evolve-lite-subscribe skill to start syncing shared guidelines.") sys.exit(0) identity = cfg.get("identity", {}) @@ -121,22 +137,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/claude/plugins/evolve-lite/lib/config.py b/platform-integrations/claude/plugins/evolve-lite/lib/config.py index 8efdb444..7b9ecf9c 100644 --- a/platform-integrations/claude/plugins/evolve-lite/lib/config.py +++ b/platform-integrations/claude/plugins/evolve-lite/lib/config.py @@ -7,7 +7,6 @@ import pathlib import re -import sys VALID_SCOPES = ("read", "write") @@ -328,7 +327,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") @@ -336,10 +340,6 @@ def _coerce_repo(entry): if not isinstance(name, str) or not name.strip(): return None if not is_valid_repo_name(name.strip()): - print( - f"evolve-lite: ignoring repo entry {name!r} — invalid 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 @@ -347,10 +347,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 +385,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/sync/scripts/sync.py b/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py index 8038e100..f2b35c5f 100755 --- a/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/sync/scripts/sync.py @@ -21,7 +21,7 @@ # 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 +from config import classify_repo_entry, load_config # noqa: E402 from audit import append as audit_append # noqa: E402 @@ -149,9 +149,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 /evolve-lite:subscribe to start syncing shared guidelines.") sys.exit(0) @@ -163,16 +178,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: name = repo.get("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 - repo_path = evolve_dir / "entities" / "subscribed" / name head_before = None 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 8efdb444..7b9ecf9c 100644 --- a/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/lib/config.py @@ -7,7 +7,6 @@ import pathlib import re -import sys VALID_SCOPES = ("read", "write") @@ -328,7 +327,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") @@ -336,10 +340,6 @@ def _coerce_repo(entry): if not isinstance(name, str) or not name.strip(): return None if not is_valid_repo_name(name.strip()): - print( - f"evolve-lite: ignoring repo entry {name!r} — invalid 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 @@ -347,10 +347,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 +385,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/sync/scripts/sync.py b/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py index 8038e100..f2b35c5f 100755 --- a/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/claw-code/plugins/evolve-lite/skills/sync/scripts/sync.py @@ -21,7 +21,7 @@ # 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 +from config import classify_repo_entry, load_config # noqa: E402 from audit import append as audit_append # noqa: E402 @@ -149,9 +149,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 /evolve-lite:subscribe to start syncing shared guidelines.") sys.exit(0) @@ -163,16 +178,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: name = repo.get("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 - repo_path = evolve_dir / "entities" / "subscribed" / name head_before = None diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py b/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py index b590511f..ba5384d3 100644 --- a/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py +++ b/platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py @@ -24,7 +24,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 +118,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 +147,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/plugin-source/lib/config.py b/plugin-source/lib/config.py index 8efdb444..7b9ecf9c 100644 --- a/plugin-source/lib/config.py +++ b/plugin-source/lib/config.py @@ -7,7 +7,6 @@ import pathlib import re -import sys VALID_SCOPES = ("read", "write") @@ -328,7 +327,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") @@ -336,10 +340,6 @@ def _coerce_repo(entry): if not isinstance(name, str) or not name.strip(): return None if not is_valid_repo_name(name.strip()): - print( - f"evolve-lite: ignoring repo entry {name!r} — invalid 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 @@ -347,10 +347,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 +385,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/plugin-source/skills/sync/scripts/sync.py b/plugin-source/skills/sync/scripts/sync.py index 8038e100..f2b35c5f 100755 --- a/plugin-source/skills/sync/scripts/sync.py +++ b/plugin-source/skills/sync/scripts/sync.py @@ -21,7 +21,7 @@ # 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 +from config import classify_repo_entry, load_config # noqa: E402 from audit import append as audit_append # noqa: E402 @@ -149,9 +149,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 /evolve-lite:subscribe to start syncing shared guidelines.") sys.exit(0) @@ -163,16 +178,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: name = repo.get("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 - repo_path = evolve_dir / "entities" / "subscribed" / name head_before = None 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"}]} From 2363dc57dfb9b5ebb63ae821104c17028c4fb223 Mon Sep 17 00:00:00 2001 From: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:33:51 -0700 Subject: [PATCH 11/33] feat(build): unify SKILL.md prose across all four platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the SKILL.codex.md / SKILL.bob.md per-platform-overlay approach (the dropped c6c76a0) with a single SKILL.md.j2 per skill that renders for all four platforms. Codex's prose is the canonical base — it is the most refined / production-tested variant — and Jinja2 branches handle the genuinely platform-specific bits. What this does for each cross-platform skill (learn, publish, recall, subscribe, sync, unsubscribe): - Frontmatter description switches to codex's trigger-oriented wording across all platforms (claude/claw-code/bob previously carried a more passive "Analyze ..." description). - claude keeps `context: fork` in the frontmatter via a Jinja branch. - learn keeps Step 0 (forked-context transcript loading) for claude only via the existing `forked_context` flag. - recall adopts codex's "Required Action / Completion Rule / Required Visible Completion Note / Failure Conditions" guards on every platform, with a per-platform "How It Works" branch that describes claude's UserPromptSubmit hook, claw-code's PreToolUse hook, codex's optional codex_hooks integration, and bob's manual workflow respectively. - sync gains a "Notes" implementation-detail section sourced from bob's prose (additive, applies to all platforms). - unsubscribe keeps the claude/claw-code-only `--force` addendum inside a `{% if platform in ["claude", "claw-code"] %}` branch because only those platforms' unsubscribe.py refuses to remove a write-scope clone without it. save-trajectory now also renders for bob (codex has no save-trajectory skill). The Write+temp-file pattern from claude applies to bob too — bob's prior heredoc form had the same escaping fragility claude's note warned against. The macro layer (_macros.j2): - `invoke(skill, script, args)` gains codex and bob branches: codex → python3 "$(git rev-parse --show-toplevel ...)/plugins/.../