Skip to content

Commit c775bd4

Browse files
committed
fix(codex): align sharing recall and publish behavior
1 parent 16e2832 commit c775bd4

13 files changed

Lines changed: 245 additions & 44 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ def find_entities_dir():
7575
return c if c.is_dir() else None
7676

7777

78+
def find_recall_entity_dirs():
79+
"""Locate all directories that should contribute to recall.
80+
81+
Recall should include both private/subscribed entities stored under
82+
``.evolve/entities`` and published guidelines stored under
83+
``.evolve/public`` when those directories exist.
84+
"""
85+
evolve_dir = get_evolve_dir()
86+
candidates = [evolve_dir / "entities", evolve_dir / "public"]
87+
return [path for path in candidates if path.is_dir()]
88+
89+
7890
def get_default_entities_dir():
7991
"""Return (and create) the default entities directory.
8092

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

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,80 @@ If you do not want to enable Codex hooks, you can still invoke the installed `ev
9999

100100
The installed Codex hook does not require `git`. It walks upward from the current working directory until it finds the repo-local `plugins/evolve-lite/.../retrieve_entities.py` script.
101101

102-
If `sync.on_session_start` is enabled in `evolve.config.yaml`, the installer also registers a `SessionStart` hook with matcher `startup|resume` so the sync helper runs quietly whenever a Codex session starts or resumes.
102+
The installer always registers a `SessionStart` hook with matcher `startup|resume`; it runs on every Codex session start or resume and exits quickly unless `sync.on_session_start` is enabled and subscriptions are configured in `evolve.config.yaml`.
103+
104+
## Sharing Guidelines
105+
106+
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.
107+
108+
### Setup
109+
110+
Sharing uses `evolve.config.yaml` at the project root. Minimal structure:
111+
112+
```yaml
113+
identity:
114+
user: yourname
115+
116+
public_repo:
117+
remote: git@github.com:yourname/evolve-guidelines.git
118+
branch: main
119+
120+
subscriptions: []
121+
122+
sync:
123+
on_session_start: true
124+
```
125+
126+
The `.evolve/` directory is kept out of version control.
127+
128+
### Publishing Guidelines
129+
130+
Use `evolve-lite:publish` to share one or more of your local guidelines with others:
131+
132+
1. Pick a file from `.evolve/entities/guideline/`
133+
2. Publish it into `.evolve/public/guideline/`
134+
3. The published file is stamped with `visibility: public`, `published_at`, and a `source` label derived from config when available
135+
4. The original private guideline is removed from `.evolve/entities/guideline/`
136+
137+
Others can then subscribe using that public remote URL.
138+
139+
### Subscribing to Guidelines
140+
141+
Use `evolve-lite:subscribe` to pull in guidelines from another user's public repo.
142+
143+
The repo is cloned to `.evolve/subscribed/{name}/` and mirrored into `.evolve/entities/subscribed/{name}/` so recall can pick them up immediately.
144+
145+
### Syncing Subscriptions
146+
147+
Use `evolve-lite:sync` to pull the latest changes from all subscribed repos.
148+
149+
If `sync.on_session_start: true` is set in config, this runs automatically whenever a Codex session starts or resumes.
150+
151+
### Unsubscribing
152+
153+
Use `evolve-lite:unsubscribe` to remove a subscription and delete its locally cloned files.
154+
155+
This removes both `.evolve/subscribed/{name}/` and its mirrored recall copy under `.evolve/entities/subscribed/{name}/`.
156+
157+
### Sharing Storage Layout
158+
159+
```text
160+
.evolve/
161+
public/
162+
guideline/
163+
guideline-name.md # published guideline, included in recall
164+
subscribed/
165+
alice/
166+
guideline/
167+
her-guideline.md # git clone of alice's public repo
168+
entities/
169+
guideline/
170+
private-guideline.md # private local guideline
171+
subscribed/
172+
alice/
173+
guideline/
174+
her-guideline.md # mirrored for recall, annotated [from: alice]
175+
```
103176

104177
## Included Skills
105178

@@ -109,7 +182,7 @@ Analyze the current session and save proactive Evolve entities as markdown files
109182

110183
### `evolve-lite:recall`
111184

112-
Show the entities already stored for the current workspace.
185+
Show the entities already stored for the current workspace, including published guidelines under `.evolve/public/`.
113186

114187
### `evolve-lite:publish`
115188

platform-integrations/codex/plugins/evolve-lite/skills/publish/scripts/publish.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import argparse
55
import datetime
66
import os
7+
import re
78
import sys
89
from pathlib import Path
910

@@ -25,6 +26,24 @@
2526
sys.path.insert(0, str(_lib))
2627
from audit import append as audit_append # noqa: E402
2728
from entity_io import entity_to_markdown, markdown_to_entity # noqa: E402
29+
from config import load_config # noqa: E402
30+
31+
32+
def _resolve_source(cfg, user_arg):
33+
"""Derive a source label from config or fallback to the provided user."""
34+
remote = cfg.get("public_repo", {})
35+
if isinstance(remote, dict):
36+
remote = remote.get("remote", "")
37+
if remote:
38+
match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", remote)
39+
if match:
40+
return match.group(1)
41+
42+
identity = cfg.get("identity", {})
43+
if isinstance(identity, dict) and identity.get("user"):
44+
return identity["user"]
45+
46+
return user_arg
2847

2948

3049
def main():
@@ -49,7 +68,11 @@ def main():
4968
entity["visibility"] = "public"
5069
if args.user:
5170
entity["owner"] = args.user
52-
entity["published_at"] = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
71+
entity["published_at"] = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
72+
config = load_config(str(evolve_dir.resolve().parent))
73+
source = _resolve_source(config, args.user)
74+
if source:
75+
entity["source"] = source
5376

5477
dest_dir = evolve_dir / "public" / "guideline"
5578
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -62,7 +85,7 @@ def main():
6285
src_path.unlink()
6386

6487
audit_append(
65-
project_root=str(evolve_dir.parent) if evolve_dir.name != ".evolve" else ".",
88+
project_root=str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent),
6689
action="publish",
6790
actor=args.user or "unknown",
6891
entity=args.entity,

platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Before any non-trivial local work, you must complete the recall workflow below.
2727

2828
Do not proceed to other analysis or tool use until all steps below are complete.
2929

30-
1. Inspect `.evolve/entities/` for guidance relevant to the current task.
30+
1. Inspect `.evolve/entities/` and `.evolve/public/` for guidance relevant to the current task.
3131
2. Read each matching entity file that appears relevant.
3232
3. Summarize the applicable guidance in your own words before proceeding.
3333
4. If no relevant entities exist, state that explicitly before proceeding.
@@ -36,12 +36,12 @@ Do not proceed to other analysis or tool use until all steps below are complete.
3636

3737
Before moving on, produce an explicit completion note in your reasoning or user update using one of these forms:
3838

39-
- `Recall complete: searched .evolve/entities/, read <files>, applicable guidance: <summary>`
40-
- `Recall complete: searched .evolve/entities/, no relevant entities found`
39+
- `Recall complete: searched .evolve/entities/ and .evolve/public/, read <files>, applicable guidance: <summary>`
40+
- `Recall complete: searched .evolve/entities/ and .evolve/public/, no relevant entities found`
4141

4242
### Minimum Acceptable Procedure
4343

44-
1. List or search files under `.evolve/entities/`.
44+
1. List or search files under `.evolve/entities/` and `.evolve/public/`.
4545
2. Identify candidate entities relevant to the task.
4646
3. Open and read those entity files.
4747
4. Summarize what applies, or state that nothing applies.
@@ -51,27 +51,34 @@ Before moving on, produce an explicit completion note in your reasoning or user
5151
The skill is not complete if any of the following are true:
5252

5353
- You only read this `SKILL.md`
54-
- You did not inspect `.evolve/entities/`
54+
- You did not inspect `.evolve/entities/` and `.evolve/public/`
5555
- You did not read the relevant entity files
5656
- You proceeded without stating whether guidance was found
5757

5858
## How It Works
5959

6060
1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent.
6161
2. The helper script reads the prompt JSON from stdin.
62-
3. It loads stored entities from `.evolve/entities/`.
62+
3. It loads stored entities from `.evolve/entities/` and `.evolve/public/`.
6363
4. It prints formatted guidance to stdout.
6464
5. Codex adds that text as extra developer context for the turn.
6565

6666
## Entities Storage
6767

68-
Entities are stored as markdown files in `.evolve/entities/`, nested by type:
68+
Entities are loaded from two locations:
6969

7070
```text
7171
.evolve/entities/
7272
guideline/
73-
use-context-managers-for-file-operations.md
74-
cache-api-responses-locally.md
73+
use-context-managers-for-file-operations.md <- private
74+
subscribed/
75+
alice/
76+
guideline/
77+
alice-tip.md <- annotated [from: alice]
78+
79+
.evolve/public/
80+
guideline/
81+
published-tip.md <- your own public, no annotation
7582
```
7683

7784
Each file uses markdown with YAML frontmatter:

platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
if _lib is None:
2323
raise ImportError(f"Cannot find plugin lib directory above {_script}")
2424
sys.path.insert(0, str(_lib))
25-
from entity_io import find_entities_dir, markdown_to_entity, log as _log # noqa: E402
25+
from entity_io import find_recall_entity_dirs, markdown_to_entity, log as _log # noqa: E402
2626

2727

2828
def log(message):
@@ -57,25 +57,26 @@ def format_entities(entities):
5757
return header + "\n".join(items)
5858

5959

60-
def load_entities_with_source(entities_dir):
61-
"""Load markdown entities and annotate mirrored subscribed content."""
60+
def load_entities_with_source(entities_dirs):
61+
"""Load markdown entities from recall roots and annotate subscribed content."""
6262
entities = []
63-
entities_dir = Path(entities_dir)
64-
for md in sorted(entities_dir.glob("**/*.md")):
65-
try:
66-
entity = markdown_to_entity(md)
67-
except OSError:
68-
continue
69-
if not entity.get("content"):
70-
continue
71-
72-
parts = md.parts
73-
for index, part in enumerate(parts):
74-
if part == "subscribed" and index + 1 < len(parts):
75-
entity["_source"] = parts[index + 1]
76-
break
77-
78-
entities.append(entity)
63+
for entities_dir in entities_dirs:
64+
entities_dir = Path(entities_dir)
65+
for md in sorted(entities_dir.glob("**/*.md")):
66+
try:
67+
entity = markdown_to_entity(md)
68+
except OSError:
69+
continue
70+
if not entity.get("content"):
71+
continue
72+
73+
parts = md.relative_to(entities_dir).parts
74+
for index, part in enumerate(parts):
75+
if part == "subscribed" and index + 1 < len(parts):
76+
entity["_source"] = parts[index + 1]
77+
break
78+
79+
entities.append(entity)
7980

8081
return entities
8182

@@ -100,13 +101,13 @@ def main():
100101
log(f" {key}={value}")
101102
log("=== End Environment Variables ===")
102103

103-
entities_dir = find_entities_dir()
104-
log(f"Entities dir: {entities_dir}")
105-
if not entities_dir:
106-
log("No entities directory found")
104+
entities_dirs = find_recall_entity_dirs()
105+
log(f"Recall entity dirs: {entities_dirs}")
106+
if not entities_dirs:
107+
log("No recall entity directories found")
107108
return
108109

109-
entities = load_entities_with_source(entities_dir)
110+
entities = load_entities_with_source(entities_dirs)
110111
if not entities:
111112
log("No entities found")
112113
return

platform-integrations/codex/plugins/evolve-lite/skills/subscribe/scripts/subscribe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ def main():
3434
parser.add_argument("--branch", default="main", help="Branch to track")
3535
args = parser.parse_args()
3636

37-
project_root = "."
3837
evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve"))
38+
project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent)
3939
subscribed_base = (evolve_dir / "subscribed").resolve()
4040
dest = (evolve_dir / "subscribed" / args.name).resolve()
4141
if not dest.is_relative_to(subscribed_base):

platform-integrations/codex/plugins/evolve-lite/skills/sync/scripts/sync.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ def main():
8080
parser.add_argument("--config", default=None, help="Explicit config path")
8181
args = parser.parse_args()
8282

83-
project_root = "."
8483
evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve"))
84+
project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent)
8585

8686
if args.config:
8787
cfg_path = Path(args.config)
@@ -114,13 +114,24 @@ def main():
114114
continue
115115
name = sub.get("name", "unknown")
116116
branch = sub.get("branch", "main")
117-
repo_path = evolve_dir / "subscribed" / name
117+
subscribed_base = (evolve_dir / "subscribed").resolve()
118+
repo_path = (evolve_dir / "subscribed" / name).resolve()
119+
120+
if repo_path == subscribed_base or not repo_path.is_relative_to(subscribed_base):
121+
summaries.append(f"{name} (invalid subscription name)")
122+
continue
118123

119124
if not repo_path.is_dir():
120125
summaries.append(f"{name} (not cloned)")
121126
continue
122127

123-
git_pull(repo_path, branch)
128+
pull_result = git_pull(repo_path, branch)
129+
if pull_result.returncode != 0:
130+
error_lines = (pull_result.stderr or pull_result.stdout or "").strip().splitlines()
131+
short_error = error_lines[-1] if error_lines else "unknown error"
132+
summaries.append(f"{name} (git pull failed: {short_error})")
133+
continue
134+
124135
delta = count_delta(repo_path)
125136
total_delta[name] = delta
126137
if any(value > 0 for value in delta.values()):

platform-integrations/codex/plugins/evolve-lite/skills/unsubscribe/scripts/unsubscribe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ def main():
3535
group.add_argument("--name", help="Name of subscription to remove")
3636
args = parser.parse_args()
3737

38-
project_root = "."
3938
evolve_dir = Path(os.environ.get("EVOLVE_DIR", ".evolve"))
39+
project_root = str(evolve_dir.resolve()) if evolve_dir.name != ".evolve" else str(evolve_dir.resolve().parent)
4040

4141
cfg = load_config(project_root)
4242
subscriptions = cfg.get("subscriptions", [])

tests/platform_integrations/test_audit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
import audit
1414

15-
pytestmark = pytest.mark.platform_integrations
15+
pytestmark = [pytest.mark.platform_integrations, pytest.mark.unit]
1616

1717

1818
class TestAuditAppend:

0 commit comments

Comments
 (0)