Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ Required: `SKILL_NAME`, `SKILL_TRIGGERS`, `SKILL_DESCRIPTION`. Optional: `SKILL_
### Adding a new skill
Drop a `.py` file in `~/.codec/skills/` (user) or `skills/` (built-in). Restart `codec-dashboard` and the main `open-codec` process. Hot-reload is not currently supported.

**Two folders (2026-10-07, docs/USER-SKILLS-DIR-DESIGN.md).** Every registry reads the built-in folder (`codec_config.SKILLS_DIR`, the repo's `skills/` unless `config.json:skills_dir` says otherwise) and then the user folder (`codec_config.USER_SKILLS_DIR`: `config.json:user_skills_dir`, default `~/.codec/skills`). A built-in name always wins; a user file is never trusted by the manifest, so every load runs the AST gate; files starting with `_` and sub-folders are skipped. `/api/skill/approve` (review-and-approve, Settings > Learning) writes approved skills to the user folder (0700 folder, 0600 file), never the repo. The test suite points `CODEC_USER_SKILLS_DIR` at a temp folder (tests/conftest.py).

## 5. Memory contract

Single SQLite database at `~/.codec/memory.db`. Wrapper: `codec_memory.CodecMemory`.
Expand Down
5 changes: 3 additions & 2 deletions codec_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@
# ── CONFIG ──
CONFIG_PATH = os.path.expanduser("~/.codec/config.json")
try:
from codec_config import SKILLS_DIR
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
except ImportError:
SKILLS_DIR = os.path.expanduser("~/.codec/skills")
USER_SKILLS_DIR = None
DB_PATH = os.path.expanduser("~/.codec/memory.db")

def _cfg():
Expand Down Expand Up @@ -408,7 +409,7 @@ def _shell_execute(cmd: str) -> str:

from codec_skill_registry import SkillRegistry

_agents_registry = SkillRegistry(SKILLS_DIR)
_agents_registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)


def _make_lazy_fn(registry: "SkillRegistry", skill_name: str):
Expand Down
6 changes: 3 additions & 3 deletions codec_autopilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
sys.path.insert(0, str(_REPO / "skills"))

from codec_skill_registry import SkillRegistry
from codec_config import SKILLS_DIR
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
from codec_audit import audit

log = logging.getLogger("codec-autopilot")
Expand Down Expand Up @@ -116,7 +116,7 @@ def _day_matches(days_spec: str, weekday: int) -> bool:
def _speak(text: str):
"""Best-effort TTS via tts_say skill; falls back to macOS `say`."""
try:
registry = SkillRegistry(SKILLS_DIR)
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
registry.scan()
mod = registry.load("tts_say")
if mod and hasattr(mod, "run"):
Expand Down Expand Up @@ -225,7 +225,7 @@ def main():
codec_lifecycle.install_handlers(
lambda: log.info("CODEC Autopilot graceful shutdown"),
name="codec-autopilot")
registry = SkillRegistry(SKILLS_DIR)
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
registry.scan()
while True:
try:
Expand Down
4 changes: 4 additions & 0 deletions codec_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,10 @@ def get_telegram_bot_token() -> str:
# Skills load from the repo directly — single source of truth
_REPO_DIR = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = cfg.get("skills_dir", os.path.join(_REPO_DIR, "skills"))
# The owner's skills (approved reviews, marketplace installs): read after the built-ins,
# never trusted by the manifest. CODEC_USER_SKILLS_DIR lets the test suite point it away.
USER_SKILLS_DIR = os.path.expanduser(os.environ.get("CODEC_USER_SKILLS_DIR")
or cfg.get("user_skills_dir") or "~/.codec/skills")
AUDIT_LOG = os.path.expanduser("~/.codec/audit.log")

# Features
Expand Down
4 changes: 2 additions & 2 deletions codec_dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ <h2 class="mcp-intro-title" id="connPermsH">macOS permissions</h2>
<div class="settings-section" id="learnProposalsSection">
<div class="settings-title">Proposed skills</div>
<div class="settings-card"><div class="push-body">
<p class="push-intro">Drafted by the self-improvement run when CODEC lacked a skill. Review shows the code; Approve and install writes it to your skills folder after the safety check, and CODEC loads it after the next restart.</p>
<p class="push-intro">Drafted by the self-improvement run when CODEC lacked a skill. Review shows the code; Approve and install writes it to your skills folder (~/.codec/skills) after the safety check, and CODEC loads it after the next restart.</p>
<div id="learnProposals" class="mem-facts"><div class="refreshing">Loading...</div></div>
<div class="push-msg" id="learnMsg" role="status" aria-live="polite"></div>
</div></div>
Expand Down Expand Up @@ -3002,7 +3002,7 @@ <h2 class="mcp-intro-title" id="connPermsH">macOS permissions</h2>
LRN.review = rid; LRN.name = p.name;
_learnDlg();
document.getElementById('learnTitle').textContent = 'Review ' + p.name;
document.getElementById('learnWhat').textContent = 'Approve and install writes ' + fname + ' to your skills folder after the safety check; CODEC loads it after the next restart.' +
document.getElementById('learnWhat').textContent = 'Approve and install writes ' + fname + ' to your skills folder (~/.codec/skills) after the safety check; CODEC loads it after the next restart.' +
(p.truncated ? ' The code is longer than shown here.' : '');
document.getElementById('learnCode').textContent = p.code;
document.getElementById('learnErr').textContent = '';
Expand Down
4 changes: 2 additions & 2 deletions codec_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
# Audit emits route through the unified log_event adapter (real, not no-op)
# per docs/PHASE1-STEP1-DESIGN.md.
from codec_audit import log_event
from codec_config import SKILLS_DIR
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
from codec_hooks import HookVeto, run_with_hooks
from codec_skill_registry import SkillRegistry
from codec_skill_switches import is_off as _skill_off, off_message as _off_message

log = logging.getLogger('codec')

# Global registry instance shared across codec.py
registry = SkillRegistry(SKILLS_DIR)
registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)


def load_skills():
Expand Down
4 changes: 2 additions & 2 deletions codec_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def _validate_mcp_input(tool_name: str, task: str, context: str = "") -> str | N
if _REPO_DIR not in sys.path:
sys.path.insert(0, _REPO_DIR)

from codec_config import MCP_DEFAULT_ALLOW, MCP_ALLOWED_TOOLS, MCP_BLOCKED_TOOLS, SKILLS_DIR
from codec_config import MCP_DEFAULT_ALLOW, MCP_ALLOWED_TOOLS, MCP_BLOCKED_TOOLS, SKILLS_DIR, USER_SKILLS_DIR
if SKILLS_DIR not in sys.path:
sys.path.insert(0, SKILLS_DIR)
from codec_skill_registry import SkillRegistry
Expand All @@ -78,7 +78,7 @@ def __iter__(self):


# Global registry for MCP skill tools
_mcp_registry = SkillRegistry(SKILLS_DIR)
_mcp_registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)


def build_mcp(auth=None):
Expand Down
5 changes: 3 additions & 2 deletions codec_self_improve.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
_REPO = Path(__file__).resolve().parent
sys.path.insert(0, str(_REPO))

from codec_config import QWEN_BASE_URL, QWEN_MODEL, SKILLS_DIR, is_dangerous_skill_code
from codec_config import QWEN_BASE_URL, QWEN_MODEL, SKILLS_DIR, USER_SKILLS_DIR, is_dangerous_skill_code

try:
from codec_audit import log_event as _si_log_event
Expand All @@ -106,7 +106,8 @@ def _si_log_event(*a, **kw): # type: ignore[no-redef]

def _existing_skill_names() -> set[str]:
out = set()
for f in Path(SKILLS_DIR).glob("*.py"):
# Built-ins and the owner's folder (approved skills): never propose what is already there.
for f in [*Path(SKILLS_DIR).glob("*.py"), *Path(USER_SKILLS_DIR).glob("*.py")]:
if f.name.startswith("_") or f.name == "codec.py":
continue
out.add(f.stem)
Expand Down
58 changes: 45 additions & 13 deletions codec_skill_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,15 @@ class SkillRegistry:
mod = registry.load("weather") # first call imports; subsequent calls return cache
"""

def __init__(self, skills_dir: str):
def __init__(self, skills_dir: str, user_dir: Optional[str] = None):
self.skills_dir = skills_dir
# The owner's folder (~/.codec/skills): read after the built-ins, a built-in
# name always wins, and its files are never trusted by the manifest (every
# load runs the AST gate). None, or the built-in folder itself, means off.
same = user_dir and os.path.realpath(user_dir) == os.path.realpath(skills_dir)
self.user_dir = None if (not user_dir or same) else user_dir
# names whose file is in user_dir (set at scan() time)
self._user_names: Set[str] = set()
# name -> metadata dict (always populated after scan)
self._meta: Dict[str, Dict[str, Any]] = {}
# name -> file path
Expand All @@ -129,9 +136,11 @@ def scan(self) -> int:
Returns the number of skills discovered."""
self._meta.clear()
self._paths.clear()
self._user_names.clear()
# Do NOT clear _modules — keep any already-loaded modules cached

if not os.path.isdir(self.skills_dir):
has_user = bool(self.user_dir) and os.path.isdir(self.user_dir)
if not os.path.isdir(self.skills_dir) and not has_user:
return 0

# Reload the trusted-skill manifest on every scan so manifest edits
Expand All @@ -140,21 +149,42 @@ def scan(self) -> int:
# Reload user trigger overrides too (A-4) — same refresh-on-scan policy.
self._custom_triggers = self._load_custom_triggers()

for fname in sorted(os.listdir(self.skills_dir)):
if not fname.endswith(".py") or fname.startswith("_"):
continue
filepath = os.path.join(self.skills_dir, fname)
meta = _extract_metadata(filepath)
if meta is None:
continue
name = meta.get("SKILL_NAME", fname[:-3])
self._meta[name] = meta
self._paths[name] = filepath
if os.path.isdir(self.skills_dir):
for fname in sorted(os.listdir(self.skills_dir)):
if not fname.endswith(".py") or fname.startswith("_"):
continue
filepath = os.path.join(self.skills_dir, fname)
meta = _extract_metadata(filepath)
if meta is None:
continue
name = meta.get("SKILL_NAME", fname[:-3])
self._meta[name] = meta
self._paths[name] = filepath

if has_user:
for fname in sorted(os.listdir(self.user_dir)):
if not fname.endswith(".py") or fname.startswith("_"):
continue
filepath = os.path.join(self.user_dir, fname)
if not os.path.isfile(filepath):
continue
meta = _extract_metadata(filepath)
if meta is None:
continue
name = meta.get("SKILL_NAME", fname[:-3])
if name in self._meta and name not in self._user_names:
log.warning("User skill %s skipped: the built-in skill %r has that name", fname, name)
continue
self._meta[name] = meta
self._paths[name] = filepath
self._user_names.add(name)

log.info(
"Skill registry: %d skills discovered (metadata only), %d in trusted manifest",
len(self._meta), len(self._trusted_hashes),
)
if self._user_names:
log.info("Skill registry: %d of them from the user folder %s", len(self._user_names), self.user_dir)
return len(self._meta)

def _load_trusted_manifest(self) -> Set[str]:
Expand Down Expand Up @@ -283,7 +313,9 @@ def load(self, name: str) -> Optional[Any]:
return None

file_hash = hashlib.sha256(raw).hexdigest()
trusted = file_hash in self._trusted_hashes
# A file in the user folder is never trusted by the manifest, even if its
# bytes equal a pinned built-in's: it always goes through the AST gate.
trusted = file_hash in self._trusted_hashes and name not in self._user_names

if not trusted:
# Decode for AST check; fail-safe on UnicodeDecodeError.
Expand Down
4 changes: 2 additions & 2 deletions codec_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,11 @@ def _cmd_skills(args: list[str]) -> str:
# Lazy import to avoid pulling skill registry at module load
try:
from codec_skill_registry import SkillRegistry
from codec_config import SKILLS_DIR
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
except Exception as e:
return f"Error: skill registry unavailable: {e}"

reg = SkillRegistry(SKILLS_DIR)
reg = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
reg.scan()
# P3.6: the deny list the Skills page writes and every path enforces (codec_skill_switches).
import codec_skill_switches as switches
Expand Down
5 changes: 3 additions & 2 deletions codec_voice.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,10 @@ def _clear_voice_session_marker() -> None:
KOKORO_VOICE = "am_adam"
KOKORO_SPEED = 1.15 # config:tts_speed overrides (normal mode; flash has its own)
try:
from codec_config import SKILLS_DIR
from codec_config import SKILLS_DIR, USER_SKILLS_DIR
except ImportError:
SKILLS_DIR = os.path.expanduser("~/.codec/skills")
USER_SKILLS_DIR = None

_CONFIG_PATH = os.path.expanduser("~/.codec/config.json")
try:
Expand Down Expand Up @@ -511,7 +512,7 @@ def _save_for_resume(self):

def _load_skills(self):
from codec_skill_registry import SkillRegistry
self._skill_registry = SkillRegistry(SKILLS_DIR)
self._skill_registry = SkillRegistry(SKILLS_DIR, USER_SKILLS_DIR)
self._skill_registry.scan()
# Build a lightweight dict with triggers only (no module imports)
for name in self._skill_registry.names():
Expand Down
56 changes: 56 additions & 0 deletions docs/USER-SKILLS-DIR-DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# User skills folder: approved skills go to ~/.codec/skills, and CODEC loads it

Asked by Mickael on 2026-10-07 ("point approve at ~/.codec/skills") after the Mac merge pass found that approved
skills land in the repo's `skills/` folder (docs/known-issues.md).

## Why

- `POST /api/skill/approve` (the review-and-approve flow, and Settings > Learning's Approve and install) writes to
`codec_config.SKILLS_DIR`. That is `config.json:skills_dir`, or the repo's own `skills/` folder when the key is not
set, as on this Mac. An approved skill becomes an untracked file in the live git tree, next to the hash-pinned
built-ins.
- Every process builds its skill registry with `SkillRegistry(SKILLS_DIR)`, and the registry scans that one folder.
So `~/.codec/skills`, which AGENTS.md calls the user folder and which the marketplace installs into, is never
loaded on this Mac. Writing approved skills there alone would make them dead files.

## What

1. `codec_config.USER_SKILLS_DIR`: `config.json:user_skills_dir`, default `~/.codec/skills`.
2. `SkillRegistry(skills_dir, user_dir=None)`: when `user_dir` is given and differs from `skills_dir`, `scan()` reads
the built-in folder first, then the user folder. A user file whose skill name is already a built-in is skipped
(logged), so a user file can never shadow a built-in. User files are never trusted by the manifest: every load
runs the AST gate (`is_dangerous_skill_code`), as unmanifested files do today. Files starting with `_` and
sub-folders are not scanned, as today.
3. The registries in `codec_dispatch`, `codec_mcp`, `codec_voice`, `codec_agents`, `codec_autopilot` and
`codec_slash_commands` pass `USER_SKILLS_DIR`.
4. `/api/skill/approve` writes to `USER_SKILLS_DIR` (folder 0700, file 0600). The built-in-name refusal and the
write-time AST gate stay as they are. The audit line `skill_approved` gains `dir: "user"`.
5. Settings > Learning's "installed" check and `codec_self_improve`'s existing-name scan look in both folders.
6. AGENTS.md §4 and the Learning page text say where approved skills go.

No change to the manifest, the built-in folder, the AST gate, the security lists or MCP exposure: a user skill's
`SKILL_MCP_EXPOSE` is honoured as before, and over HTTP the blocked and ask-first lists apply.

## Migration (needs Mickael's OK: it is his folder)

`~/.codec/skills` holds 6 old files (March-April 2026) that have never been loaded on this Mac. Five fail the AST
gate. `lucy.py` passes, so it would start running. Before the first restart they move to
`~/.codec/skills/_dormant-2026-10-07/` (not scanned; nothing deleted). Any of them can be moved back by hand.

## Deploy

Restart `codec-dashboard` and `open-codec`. Until Mickael restarts `codec-mcp-http`, `codec-agent-runner` and
`codec-autopilot`, those keep scanning the repo folder only, so an approved skill is not yet reachable from Claude
over MCP, project agents or autopilot.

## Test plan

`tests/test_user_skills_dir.py`: the registry finds built-in and user skills; a built-in wins a name clash; a user
file is never trusted by hash and a dangerous one is refused at load; `_` files and sub-folders are skipped; approve
writes to the user folder with 0600 and still refuses a built-in name; the Learning installed flag and the
self-improve name scan see user skills. Full suite with an empty HOME (CI has no ~/.codec).

## Rollback

Revert the PR. Skills approved meanwhile stay in `~/.codec/skills` and are no longer loaded; move them to the repo
folder by hand if needed.
2 changes: 1 addition & 1 deletion docs/known-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ one waiting for approval or paused, where it is the time since it started. Fix:
"started N min ago" unless the project is running. Found in the Mac merge pass.
Revisit: the next item that touches the Activity board.

## Approved skills are written into the repo's `skills/` folder (2026-10-07)
## Approved skills are written into the repo's `skills/` folder (2026-10-07) — FIXED (user skills folder, docs/USER-SKILLS-DIR-DESIGN.md: approve writes to ~/.codec/skills and every registry reads it)

`POST /api/skill/approve` (the review-and-approve flow, and Settings > Learning's
Approve and install since P3.12) writes to `routes._shared._get_skills_dir()`,
Expand Down
5 changes: 5 additions & 0 deletions routes/learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ def _installed() -> set:
names |= {f[:-3] for f in os.listdir(_get_skills_dir()) if f.endswith(".py")}
except OSError:
pass
try: # approved skills go to the user folder; one approved before a restart is not in the registry yet
from codec_config import USER_SKILLS_DIR
names |= {f[:-3] for f in os.listdir(USER_SKILLS_DIR) if f.endswith(".py") and not f.startswith("_")}
except OSError:
pass
return names


Expand Down
19 changes: 15 additions & 4 deletions routes/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ async def skill_review(request: Request):
}


def _approved_skills_dir() -> str:
"""Where an approved skill is written: the owner's folder (codec_config.USER_SKILLS_DIR,
~/.codec/skills by default), never the repo's built-in folder. The registry reads it
after the built-ins (docs/USER-SKILLS-DIR-DESIGN.md)."""
from codec_config import USER_SKILLS_DIR
return USER_SKILLS_DIR


@router.post("/api/skill/approve")
async def skill_approve(request: Request):
"""Approve a pending skill review -- writes to disk and removes from pending."""
Expand Down Expand Up @@ -233,11 +241,14 @@ async def skill_approve(request: Request):
dangerous, reason = is_dangerous_skill_code(code)
if dangerous:
return JSONResponse({"error": f"Blocked: {reason}"}, status_code=400)
skill_dir = _get_skills_dir()
os.makedirs(skill_dir, exist_ok=True)
skill_dir = _approved_skills_dir()
os.makedirs(skill_dir, mode=0o700, exist_ok=True)
path = os.path.join(skill_dir, filename)
with open(path, "w") as f:
tmp = path + ".tmp" # not a .py, so a scan never reads a half-written file
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(code)
os.replace(tmp, path)
# Only now that the skill is on disk do we clear the staged review (from both
# disk and the in-memory cache) so a crash mid-write leaves it re-approvable.
_delete_review(review_id)
Expand All @@ -247,7 +258,7 @@ async def skill_approve(request: Request):
"skill_approved", source="codec-routes-skills",
message=f"approved skill {filename} ({len(code)} bytes) → {path}",
level="info", outcome="ok",
extra={"filename": filename, "review_id": review_id, "size": len(code)},
extra={"filename": filename, "review_id": review_id, "size": len(code), "dir": "user"},
)
except Exception:
pass
Expand Down
Loading
Loading