Skip to content

Commit 1fb356f

Browse files
illeatmyhatclaude
andcommitted
feat(evolve-lite): unify sharing into scoped repos list (#217)
Collapse public_repo and subscriptions into a single repos: list where each entry has scope: read (subscribe only) or scope: write (publish target, also synced) — closes #217. Update publish / subscribe / sync / unsubscribe / recall skills for both Claude and Codex plugins to operate on the unified model. Write-scope repos double as publish targets and sync sources, so co-writers see each other's publishes through normal sync. Add helpers in lib/config.py (normalize_repos, read_repos, write_repos, set_repos, get_repo, is_valid_repo_name) and broaden tests across tests/platform_integrations/. Sync becomes scope-aware: read = fetch + reset --hard (clobber local), write = fetch + rebase (preserve local unpushed publishes). Symlink filtering moves from sync to retrieve, since sync no longer mirrors and just maintains the clone. Publish requires the write-scope clone to already exist (subscribe/sync own that step) and writes into the clone's working tree; the SKILL.md drives the commit/push flow, including conflict-recovery via rebase + push-with-retry. Unsubscribe warns when removing a write-scope repo (in SKILL.md). CodeRabbit review pass: - _coerce_repo strips and warns on unknown scope values - publish.py uses tz-aware datetime.now(datetime.timezone.utc) - claude sync.py: --quiet no longer doubles as a session-start gate; failure branches set any_changes = True so summaries surface - publish SKILL.md hardens git clone with the -- separator - publish SKILL.md stages only the published files (git add -- {guideline_paths}), not the whole clone - codex publish SKILL.md: fix bare git -C {repo} in the conflict-diff command to use the full clone path Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7364300 commit 1fb356f

31 files changed

Lines changed: 1557 additions & 710 deletions

File tree

platform-integrations/claude/plugins/evolve-lite/README.md

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -57,86 +57,108 @@ You can also manually invoke `/evolve-lite:learn` at any time.
5757
5858
## Sharing Guidelines
5959

60-
Evolve Lite supports sharing guidelines between users via public Git repositories. You can publish your own guidelines so others can subscribe to them, and subscribe to guidelines published by others.
60+
Evolve Lite treats shared guidelines as multi-reader / multi-writer git
61+
databases. A single unified `repos:` list in `evolve.config.yaml` describes
62+
every external guideline repo you read from or publish to; each entry has a
63+
`scope` of `read` (subscribe only) or `write` (publish target, also synced).
6164

6265
### Setup
6366

64-
Sharing requires an `evolve.config.yaml` at the project root. If it doesn't exist, the subscribe or publish skills will prompt you to create one. Minimal structure:
67+
Sharing requires an `evolve.config.yaml` at the project root. The subscribe
68+
and publish skills will help you create one if it is missing. Structure:
6569

6670
```yaml
6771
identity:
6872
user: yourname # used to stamp ownership on published guidelines
69-
public_repo:
70-
remote: git@github.com:yourname/evolve-guidelines.git
71-
branch: main
72-
subscriptions: []
73+
repos:
74+
- name: memory
75+
scope: write
76+
remote: git@github.com:yourname/evolve-memory.git
77+
branch: main
78+
notes: public memory for my open-source projects
79+
- name: org-memory
80+
scope: read
81+
remote: git@github.com:acme/org-memory.git
82+
branch: main
83+
notes: private memory shared only within my org
7384
sync:
7485
on_session_start: true # auto-sync on each session start
7586
```
7687
77-
The `.evolve/` directory is kept out of version control — the skills automatically add it to `.gitignore`.
88+
- `scope: read` — pulled on sync. Cannot be published to.
89+
- `scope: write` — publish target **and** pulled on sync (so you see
90+
everything pushed to it, including by other writers).
7891

79-
### Publishing Guidelines
80-
81-
Use `/evolve-lite:publish` to share one or more of your local guidelines with others:
82-
83-
1. The skill lists files in `.evolve/entities/guideline/`
84-
2. You pick which ones to publish
85-
3. Each selected file is moved into `.evolve/public/guideline/`, stamped with `visibility: public`, `published_at`, and your username as the owner, committed, and pushed to your `public_repo.remote`
92+
The `.evolve/` directory is kept out of version control — the skills
93+
automatically add it to `.gitignore`.
8694

87-
Others can then subscribe using that remote URL.
95+
### Subscribing to a Repo
8896

89-
### Subscribing to Guidelines
90-
91-
Use `/evolve-lite:subscribe` to pull in guidelines from another user's public repo:
97+
Use `/evolve-lite:subscribe` to add either a read-only subscription or a
98+
write-scope publish target:
9299

93100
```text
94101
/evolve-lite:subscribe
95102
> Remote URL: git@github.com:alice/evolve-guidelines.git
96103
> Short name: alice
104+
> Scope: read
97105
```
98106

99-
The repo is cloned to `.evolve/subscribed/alice/` and mirrored into `.evolve/entities/subscribed/alice/` so recall picks them up immediately.
100-
The repo is cloned directly into `.evolve/entities/subscribed/alice/` so recall can pick it up immediately. Subscription names must use only letters, numbers, `.`, `_`, and `-`.
101-
102-
### Syncing Subscriptions
107+
The repo is cloned into `.evolve/entities/subscribed/alice/` so recall can
108+
pick it up immediately. Repo names must use only letters, numbers, `.`,
109+
`_`, and `-`.
103110

104-
Use `/evolve-lite:sync` to pull the latest changes from all subscribed repos:
111+
### Publishing Guidelines
105112

106-
```text
107-
/evolve-lite:sync
108-
> Synced 2 repo(s): alice (+2 added, 0 updated, 0 removed), bob (+0 added, 1 updated, 0 removed)
109-
```
113+
Use `/evolve-lite:publish` to share one or more of your local guidelines
114+
via a **write-scope** repo:
110115

111-
If `sync.on_session_start: true` is set in config, this runs automatically at the start of each session.
116+
1. The skill selects (or asks about) the write-scope target repo
117+
2. It lists files in `.evolve/entities/guideline/`
118+
3. You pick which ones to publish
119+
4. Each selected file is moved into `.evolve/entities/subscribed/{repo}/guideline/`,
120+
stamped with `visibility: public`, `owner`, `published_at`, and
121+
`source`, committed, and pushed to the remote
112122

113-
> **Note:** Sync uses `git fetch` + `git reset --hard` on each cloned subscription repo, so local state always matches the remote exactly. Accidentally deleted or modified files are automatically restored on the next sync.
123+
Because the publish target is also a subscribed repo, your next sync will
124+
pull in anything other writers have pushed to the same repo.
114125

115-
### Unsubscribing
126+
### Syncing Repos
116127

117-
Use `/evolve-lite:unsubscribe` to remove a subscription and delete its locally cloned files:
128+
Use `/evolve-lite:sync` to pull the latest changes from every configured
129+
repo (both scopes):
118130

119131
```text
120-
/evolve-lite:unsubscribe
121-
> Which subscription would you like to remove?
122-
> 1. alice
123-
> 2. bob
132+
/evolve-lite:sync
133+
> Synced 2 repo(s): memory [write] (+2 added, 0 updated, 0 removed), bob [read] (+0 added, 1 updated, 0 removed)
124134
```
125135

126-
The skill confirms before deleting `.evolve/entities/subscribed/{name}/`.
136+
If `sync.on_session_start: true` is set in config, this runs automatically
137+
at the start of each session.
138+
139+
> **Note:** Read-scope repos use `git fetch` + `git reset --hard`, so the
140+
> local clone always matches the remote exactly (deleted or modified files
141+
> are restored). Write-scope repos use `git fetch` + `git rebase` so any
142+
> unpushed local publish commits are preserved.
143+
144+
### Removing a Repo
145+
146+
Use `/evolve-lite:unsubscribe` to remove any repo and delete its local
147+
clone. The skill shows scope and notes for each configured repo and warns
148+
before removing a write-scope repo (unpushed publishes would be lost).
127149

128150
### Sharing Storage Layout
129151

130152
```text
131153
.evolve/
132-
public/
133-
guideline/
134-
guideline-name.md # owner-stamped published guideline
135154
entities/
136155
guideline/
137156
private-guideline.md
138157
subscribed/
139-
alice/ # git clone used directly by recall
158+
memory/ # write-scope clone — publishes land here
159+
guideline/
160+
my-published-guideline.md
161+
alice/ # read-scope clone
140162
guideline/
141163
her-guideline.md
142164
```
@@ -232,6 +254,22 @@ evolve/
232254
│ │ ├── SKILL.md
233255
│ │ └── scripts/
234256
│ │ └── retrieve_entities.py
257+
│ ├── subscribe/
258+
│ │ ├── SKILL.md
259+
│ │ └── scripts/
260+
│ │ └── subscribe.py
261+
│ ├── publish/
262+
│ │ ├── SKILL.md
263+
│ │ └── scripts/
264+
│ │ └── publish.py
265+
│ ├── sync/
266+
│ │ ├── SKILL.md
267+
│ │ └── scripts/
268+
│ │ └── sync.py
269+
│ ├── unsubscribe/
270+
│ │ ├── SKILL.md
271+
│ │ └── scripts/
272+
│ │ └── unsubscribe.py
235273
│ ├── save/
236274
│ │ └── SKILL.md
237275
│ └── save-trajectory/

platform-integrations/claude/plugins/evolve-lite/lib/config.py

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
"""
77

88
import pathlib
9+
import re
10+
import sys
11+
12+
13+
VALID_SCOPES = ("read", "write")
14+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]+$")
915

1016

1117
# ---------------------------------------------------------------------------
@@ -316,21 +322,145 @@ def save_config(cfg, project_root="."):
316322
path.write_text(content + "\n", encoding="utf-8")
317323

318324

325+
# ---------------------------------------------------------------------------
326+
# Unified repo model (issue #217)
327+
# ---------------------------------------------------------------------------
328+
329+
330+
def _coerce_repo(entry):
331+
"""Normalize a single repo dict. Returns None if required fields are missing."""
332+
if not isinstance(entry, dict):
333+
return None
334+
name = entry.get("name")
335+
remote = entry.get("remote")
336+
if not isinstance(name, str) or not name.strip():
337+
return None
338+
if not isinstance(remote, str) or not remote.strip():
339+
return None
340+
scope = entry.get("scope", "read")
341+
if isinstance(scope, str):
342+
scope = scope.strip()
343+
if scope not in VALID_SCOPES:
344+
print(
345+
f"evolve-lite: ignoring repo entry {name!r} — unknown scope {entry.get('scope')!r} (expected one of {', '.join(VALID_SCOPES)})",
346+
file=sys.stderr,
347+
)
348+
return None
349+
branch = entry.get("branch", "main")
350+
if not isinstance(branch, str) or not branch.strip():
351+
branch = "main"
352+
notes = entry.get("notes", "")
353+
if not isinstance(notes, str):
354+
notes = ""
355+
return {
356+
"name": name.strip(),
357+
"scope": scope,
358+
"remote": remote.strip(),
359+
"branch": branch.strip(),
360+
"notes": notes,
361+
}
362+
363+
364+
def normalize_repos(cfg):
365+
"""Return the unified ``repos`` list from *cfg* with invalid entries dropped.
366+
367+
Invalid entries (missing ``name`` or ``remote``, duplicate names, unknown
368+
scopes) are silently skipped so callers can trust every returned dict.
369+
"""
370+
if not isinstance(cfg, dict):
371+
return []
372+
raw = cfg.get("repos")
373+
if not isinstance(raw, list):
374+
return []
375+
result = []
376+
seen = set()
377+
for entry in raw:
378+
repo = _coerce_repo(entry)
379+
if repo is None or repo["name"] in seen:
380+
continue
381+
seen.add(repo["name"])
382+
result.append(repo)
383+
return result
384+
385+
386+
def get_repo(cfg, name):
387+
"""Return the repo with the given name, or None."""
388+
for repo in normalize_repos(cfg):
389+
if repo.get("name") == name:
390+
return repo
391+
return None
392+
393+
394+
def write_repos(cfg):
395+
"""Return only the write-scope repos."""
396+
return [r for r in normalize_repos(cfg) if r.get("scope") == "write"]
397+
398+
399+
def read_repos(cfg):
400+
"""Return only the read-scope repos."""
401+
return [r for r in normalize_repos(cfg) if r.get("scope") == "read"]
402+
403+
404+
def set_repos(cfg, repos):
405+
"""Replace the ``repos`` list in-place with sanitized entries."""
406+
if not isinstance(cfg, dict):
407+
return cfg
408+
sanitized = []
409+
seen = set()
410+
for entry in repos or []:
411+
repo = _coerce_repo(entry)
412+
if repo is None or repo["name"] in seen:
413+
continue
414+
seen.add(repo["name"])
415+
sanitized.append(repo)
416+
cfg["repos"] = sanitized
417+
return cfg
418+
419+
420+
def is_valid_repo_name(name):
421+
"""Return True if *name* is safe to use as a repo / directory name.
422+
423+
Rejects leading '-' so names can't be confused with git CLI flags when
424+
interpolated into clone paths.
425+
"""
426+
if not isinstance(name, str):
427+
return False
428+
if name in (".", "..") or name.startswith("-"):
429+
return False
430+
return bool(_SAFE_NAME.match(name))
431+
432+
319433
if __name__ == "__main__":
320434
# Quick self-test
321435
import tempfile
322436

323437
with tempfile.TemporaryDirectory() as d:
324438
cfg = {
325439
"identity": {"user": "alice"},
326-
"public_repo": {"remote": "git@github.com:alice/evolve.git", "branch": "main"},
327-
"subscriptions": [{"name": "bob", "remote": "git@github.com:bob/evolve.git", "branch": "main"}],
440+
"repos": [
441+
{
442+
"name": "memory",
443+
"scope": "write",
444+
"remote": "git@github.com:alice/evolve.git",
445+
"branch": "main",
446+
"notes": "public memory for foobar project",
447+
},
448+
{
449+
"name": "bob",
450+
"scope": "read",
451+
"remote": "git@github.com:bob/evolve.git",
452+
"branch": "main",
453+
"notes": "",
454+
},
455+
],
328456
"sync": {"on_session_start": True},
329457
}
330458
save_config(cfg, d)
331459
loaded = load_config(d)
332460
assert loaded["identity"]["user"] == "alice", loaded
333461
assert loaded["sync"]["on_session_start"] is True, loaded
334-
assert isinstance(loaded["subscriptions"], list), loaded
335-
assert loaded["subscriptions"][0]["name"] == "bob", loaded
462+
repos = normalize_repos(loaded)
463+
assert len(repos) == 2, repos
464+
assert repos[0]["scope"] == "write", repos
465+
assert repos[1]["name"] == "bob", repos
336466
print("config.py ok")

platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ def find_entities_dir():
7878
def find_recall_entity_dirs():
7979
"""Locate all directories that should be searched during recall.
8080
81-
Returns the existing recall roots in priority order:
82-
``entities/`` first, then ``public/`` under the configured Evolve dir.
83-
Missing directories are skipped.
81+
Returns the existing recall roots. Only ``entities/`` is canonical —
82+
private entities live in ``entities/guideline/`` and shared entities
83+
live in ``entities/subscribed/{repo}/guideline/``.
8484
"""
8585
evolve_dir = get_evolve_dir()
86-
candidates = [evolve_dir / "entities", evolve_dir / "public"]
86+
candidates = [evolve_dir / "entities"]
8787
return [path for path in candidates if path.is_dir()]
8888

8989

0 commit comments

Comments
 (0)