diff --git a/src/skills/README.md b/src/skills/README.md index 3ef9dcb9..16bd26f6 100644 --- a/src/skills/README.md +++ b/src/skills/README.md @@ -3,7 +3,7 @@ **Purpose:** Capability framework for AI agents in AIPass. Skills are discoverable, validatable, and executable units of capability that any AI agent can use. **Module:** `skills` **Created:** 2026-03-07 -**Last Updated:** 2026-03-08 +**Last Updated:** 2026-03-17 --- @@ -130,14 +130,18 @@ src/skills/ loader.py # Load SKILL.md + handlers runner.py # Execute skills creator.py # Scaffold new skills + validator.py # Check skill requirements handlers/ + json/ # JSON handler (three-JSON pattern) registry.py # Skill registry management validator.py # Check requirements template.py # Skill templates + json_templates/ # JSON auto-creation templates plugins/ # Plugin extensions catalog/ # Built-in skills templates/ # Skill creation templates skills_json/ # JSON tracking directory + dropbox/ # External storage sync .trinity/ # Branch identity and memory tests/ # Test suite ``` @@ -157,4 +161,4 @@ src/skills/ --- -*Last Updated: 2026-03-08* \ No newline at end of file +*Last Updated: 2026-03-17* \ No newline at end of file diff --git a/src/skills/apps/handlers/__init__.py b/src/skills/apps/handlers/__init__.py index 88097fcb..3923705f 100644 --- a/src/skills/apps/handlers/__init__.py +++ b/src/skills/apps/handlers/__init__.py @@ -1,13 +1,121 @@ -# ===================AIPASS==================== -# META DATA HEADER -# Name: __init__.py - Skills handlers package -# Date: 2026-03-07 -# Version: 1.0.0 -# Category: skills/apps/handlers -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2026-03-07): Initial implementation -# -# CODE STANDARDS: -# - Handlers layer: implementation details (returns dicts, NEVER prints) -# ============================================= +"""Skills handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "skills" + + +def _find_real_caller(): + """ + Walk the stack to find the actual file that triggered this import. + + Skips: + - This file (handlers/__init__.py) + - Python's importlib internals + - Frozen modules + + Returns tuple: (file_path, import_line) or (None, None) + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + # Skip this file + if this_file in str(Path(filename).resolve()): + continue + + # Skip Python internals + if filename.startswith("<") or "importlib" in filename: + continue + + # Found a real file - try to get the import line + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = Path(filepath).parts + for i, part in enumerate(parts): + if part in ("aipass", "MEMORY_BANK", "Nexus"): + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """ + Block cross-branch handler imports. + + Only code from within the 'skills' branch can import these handlers. + External branches must use skills.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" For full standards guide:\n" + f" drone @seedgo handlers\n" + f"{'='*60}" + ) + return + + # Check if caller is from our branch + if f"/{MY_BRANCH}/" in caller_file: + return + + # External caller - block access + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" For full standards guide:\n" + f" drone @seedgo handlers\n" + f"{'='*60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/skills/apps/handlers/creator_handler.py b/src/skills/apps/handlers/creator_handler.py index e15b7a85..55d0f5c0 100644 --- a/src/skills/apps/handlers/creator_handler.py +++ b/src/skills/apps/handlers/creator_handler.py @@ -20,6 +20,7 @@ Purpose: from pathlib import Path from aipass.prax import logger +from skills.apps.handlers.json import json_handler from skills.apps.handlers.template import copy_template, get_template # logger imported from aipass.prax @@ -93,6 +94,12 @@ def create_skill(name, template_type="markdown_only", target_dir=None): # Copy template result = copy_template(template_result["path"], target_path, name) + json_handler.log_operation("skill_scaffold", { + "name": name, + "template_type": template_type, + "success": result["success"], + }) + return { "success": result["success"], "path": str(target_path) if result["success"] else None, diff --git a/src/skills/apps/handlers/discovery_handler.py b/src/skills/apps/handlers/discovery_handler.py index 75c30b75..7cd118c1 100644 --- a/src/skills/apps/handlers/discovery_handler.py +++ b/src/skills/apps/handlers/discovery_handler.py @@ -20,6 +20,7 @@ Purpose: from pathlib import Path from aipass.prax import logger +from skills.apps.handlers.json import json_handler # Try yaml, fall back to simple parser yaml = None @@ -84,6 +85,8 @@ def discover_skills_in_path(search_path, source_label): metadata = parse_frontmatter(skill_md) if metadata is None: continue + if not isinstance(metadata, dict): + continue skills.append({ "name": metadata.get("name", item.name), @@ -94,6 +97,11 @@ def discover_skills_in_path(search_path, source_label): "tags": metadata.get("tags", []), }) + json_handler.log_operation("discovery_scan", { + "path": str(path), + "source": source_label, + "found": len(skills), + }) return skills @@ -141,7 +149,7 @@ def _extract_frontmatter(content): frontmatter_text = "\n".join(lines[1:end_idx]) - if HAS_YAML: + if yaml is not None: try: return yaml.safe_load(frontmatter_text) except yaml.YAMLError: diff --git a/src/skills/apps/handlers/json/__init__.py b/src/skills/apps/handlers/json/__init__.py new file mode 100644 index 00000000..df96d94e --- /dev/null +++ b/src/skills/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""Skills JSON handler package.""" diff --git a/src/skills/apps/handlers/json/json_handler.py b/src/skills/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..157403b1 --- /dev/null +++ b/src/skills/apps/handlers/json/json_handler.py @@ -0,0 +1,216 @@ +# =================== AIPass ==================== +# Name: json_handler.py +# Description: Auto-Creating JSON Handler +# Version: 1.0.0 +# Created: 2026-03-17 +# Modified: 2026-03-17 +# ============================================= + +""" +JSON Handler - Auto-Creating & Self-Healing JSON System + +Handles default JSON files (config, data, log) for skills modules. +Never manually create JSONs - they build themselves. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional +import inspect + + +# Infrastructure +_BRANCH_ROOT = Path(__file__).resolve().parents[3] + +# Constants +SKILLS_JSON_DIR = _BRANCH_ROOT / "skills_json" +JSON_TEMPLATES_DIR = _BRANCH_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack. + + Returns: + Module name (e.g., "discovery" from discovery.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file.""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type.""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file.""" + filename = f"{module_name}_{json_type}.json" + return SKILLS_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing.""" + SKILLS_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + except Exception: + pass + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing.""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file.""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module.""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation. + + Auto-detects calling module if module_name not provided. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data + + log.append(entry) + + # Rotate if exceeds max + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) diff --git a/src/skills/apps/handlers/loader_handler.py b/src/skills/apps/handlers/loader_handler.py index 94833bdb..61eafeb5 100644 --- a/src/skills/apps/handlers/loader_handler.py +++ b/src/skills/apps/handlers/loader_handler.py @@ -23,6 +23,7 @@ from pathlib import Path from aipass.prax import logger from skills.apps.handlers.discovery_handler import parse_frontmatter +from skills.apps.handlers.json import json_handler def parse_full_skill_md(skill_md_path): @@ -162,9 +163,14 @@ def load_skill(name, registry): # Import handler if present handler = None - if metadata.get("has_handler", False): + if isinstance(metadata, dict) and metadata.get("has_handler", False): handler = import_handler(skill_path, name) + json_handler.log_operation("skill_load", { + "name": name, + "has_handler": handler is not None, + }) + return { "success": True, "metadata": metadata, diff --git a/src/skills/apps/handlers/registry.py b/src/skills/apps/handlers/registry.py index a41d06a3..031ddf7f 100644 --- a/src/skills/apps/handlers/registry.py +++ b/src/skills/apps/handlers/registry.py @@ -8,6 +8,8 @@ from pathlib import Path +from skills.apps.handlers.json import json_handler + def build_registry(search_paths, discover_fn): """Discover and cache all skills from search paths. @@ -36,6 +38,11 @@ def build_registry(search_paths, discover_fn): seen_names.add(skill["name"]) registry.append(skill) + json_handler.log_operation("registry_built", { + "paths_scanned": len(search_paths), + "skills_found": len(registry), + }) + return registry diff --git a/src/skills/apps/handlers/runner_handler.py b/src/skills/apps/handlers/runner_handler.py index 03dbee87..3f7a95e0 100644 --- a/src/skills/apps/handlers/runner_handler.py +++ b/src/skills/apps/handlers/runner_handler.py @@ -17,6 +17,8 @@ Purpose: orchestration layer to satisfy thin-module standard. """ +from skills.apps.handlers.json import json_handler + def run_handler(handler, name, action, args, config): """Run a skill's handler module. @@ -63,6 +65,11 @@ def run_handler(handler, name, action, args, config): try: result = handler.run(action, args=args, config=config) + json_handler.log_operation("handler_executed", { + "name": name, + "action": action, + "success": True, + }) if isinstance(result, dict): return { "success": result.get("success", False), diff --git a/src/skills/apps/handlers/template.py b/src/skills/apps/handlers/template.py index 5eda5fe0..b056b8d7 100644 --- a/src/skills/apps/handlers/template.py +++ b/src/skills/apps/handlers/template.py @@ -9,6 +9,8 @@ import shutil from pathlib import Path +from skills.apps.handlers.json import json_handler + # Template directory lives at src/skills/templates/ TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates" @@ -81,6 +83,12 @@ def copy_template(template_path, target_path, skill_name): # Skip binary files pass + json_handler.log_operation("template_copied", { + "template": str(template_path.name), + "target": str(target), + "files_count": len(created_files), + }) + return { "success": True, "created_files": sorted(created_files), diff --git a/src/skills/apps/handlers/validator.py b/src/skills/apps/handlers/validator.py index 543ff90d..c1f289ef 100644 --- a/src/skills/apps/handlers/validator.py +++ b/src/skills/apps/handlers/validator.py @@ -10,6 +10,8 @@ import importlib.util import os import shutil +from skills.apps.handlers.json import json_handler + def validate_skill(skill_metadata): """Check if a skill's requirements are met. @@ -40,6 +42,11 @@ def validate_skill(skill_metadata): valid = not (missing_pip or missing_bins or missing_config) + json_handler.log_operation("validation_check", { + "valid": valid, + "missing_count": len(missing_pip) + len(missing_bins) + len(missing_config), + }) + return { "valid": valid, "missing_pip": missing_pip, diff --git a/src/skills/apps/json_templates/default/config.json b/src/skills/apps/json_templates/default/config.json new file mode 100644 index 00000000..9f7e5454 --- /dev/null +++ b/src/skills/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "{{TIMESTAMP}}", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/skills/apps/json_templates/default/data.json b/src/skills/apps/json_templates/default/data.json new file mode 100644 index 00000000..c88b23de --- /dev/null +++ b/src/skills/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "{{TIMESTAMP}}", + "last_updated": "{{TIMESTAMP}}", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/skills/apps/json_templates/default/log.json b/src/skills/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/skills/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/skills/apps/modules/creator.py b/src/skills/apps/modules/creator.py index b0a77074..1c5c6d42 100644 --- a/src/skills/apps/modules/creator.py +++ b/src/skills/apps/modules/creator.py @@ -17,6 +17,7 @@ Thin orchestration layer - delegates to creator_handler for logic. from aipass.prax import logger from aipass.cli.apps.modules import console, error from skills.apps.handlers.creator_handler import create_skill as _handler_create_skill +from skills.apps.handlers.json import json_handler try: from aipass.trigger.apps.modules.core import trigger @@ -34,6 +35,10 @@ def handle_command(command: str, args: list) -> bool: Returns: bool: True if command was handled, False otherwise. """ + if not args: + print_introspection() + return True + if command == "create": if not args: error("Error: skill name required. Usage: skills create [--with-handler|--full]") @@ -79,6 +84,11 @@ def create_skill(name, template_type="markdown_only", target_dir=None): for f in result["files"]: console.print(f" - {f}") + json_handler.log_operation("skill_created", { + "name": name, + "template_type": template_type, + "success": result["success"], + }) return result diff --git a/src/skills/apps/modules/discovery.py b/src/skills/apps/modules/discovery.py index a6bcfeb0..f98518c7 100644 --- a/src/skills/apps/modules/discovery.py +++ b/src/skills/apps/modules/discovery.py @@ -20,6 +20,7 @@ from skills.apps.handlers.discovery_handler import ( parse_frontmatter, ) from skills.apps.handlers.registry import build_registry +from skills.apps.handlers.json import json_handler def handle_command(command: str, args: list) -> bool: @@ -32,6 +33,10 @@ def handle_command(command: str, args: list) -> bool: Returns: bool: True if command was handled, False otherwise. """ + if not args: + print_introspection() + return True + if command in ("discover", "list"): skills = discover_all() @@ -76,7 +81,9 @@ def discover_all(): (first match wins). """ search_paths = get_search_paths() - return build_registry(search_paths, discover_skills_in_path) + result = build_registry(search_paths, discover_skills_in_path) + json_handler.log_operation("skills_discovered", {"count": len(result)}) + return result def print_introspection(): diff --git a/src/skills/apps/modules/loader.py b/src/skills/apps/modules/loader.py index 31cc4ac4..7d689f8d 100644 --- a/src/skills/apps/modules/loader.py +++ b/src/skills/apps/modules/loader.py @@ -16,6 +16,7 @@ from aipass.cli.apps.modules import console, warning from aipass.prax import logger from skills.apps.modules.discovery import discover_all from skills.apps.handlers.loader_handler import load_skill as _handler_load_skill +from skills.apps.handlers.json import json_handler def handle_command(command: str, args: list) -> bool: @@ -31,6 +32,10 @@ def handle_command(command: str, args: list) -> bool: Returns: bool: Always False - loader is a service module, not a command handler. """ + if not args: + print_introspection() + return True + return False @@ -61,6 +66,10 @@ def load_skill(name): if result["success"] and result["handler"] is None and result["metadata"].get("has_handler", False): warning(f"Warning: has_handler is true but handler.py not found at {result['path']}") + json_handler.log_operation("skill_loaded", { + "name": name, + "success": result["success"], + }) return result diff --git a/src/skills/apps/modules/runner.py b/src/skills/apps/modules/runner.py index 231cd31b..31331eb3 100644 --- a/src/skills/apps/modules/runner.py +++ b/src/skills/apps/modules/runner.py @@ -16,6 +16,7 @@ from aipass.prax import logger from aipass.cli.apps.modules import console, error from skills.apps.modules.loader import load_skill from skills.apps.handlers.runner_handler import run_handler, run_markdown +from skills.apps.handlers.json import json_handler def handle_command(command: str, args: list) -> bool: @@ -28,6 +29,10 @@ def handle_command(command: str, args: list) -> bool: Returns: bool: True if command was handled, False otherwise. """ + if not args: + print_introspection() + return True + if command == "run": if not args: error("Error: skill name required. Usage: skills run [action] [args...]") @@ -96,9 +101,16 @@ def run_skill(name, action=None, args=None, config=None): # Delegate to handler for execution if handler is not None: - return run_handler(handler, name, action, args, config) + result = run_handler(handler, name, action, args, config) + else: + result = run_markdown(name, metadata, body) - return run_markdown(name, metadata, body) + json_handler.log_operation("skill_executed", { + "name": name, + "success": result["success"], + "has_handler": handler is not None, + }) + return result def print_introspection(): diff --git a/src/skills/apps/modules/validator.py b/src/skills/apps/modules/validator.py index e1b70161..9b8d5de5 100644 --- a/src/skills/apps/modules/validator.py +++ b/src/skills/apps/modules/validator.py @@ -15,6 +15,7 @@ checking skill requirements (pip packages, CLI bins, config/env vars). from aipass.prax import logger from aipass.cli.apps.modules import console, error from skills.apps.handlers.validator import validate_skill as _handler_validate +from skills.apps.handlers.json import json_handler def handle_command(command: str, args: list) -> bool: @@ -27,6 +28,10 @@ def handle_command(command: str, args: list) -> bool: Returns: bool: True if command was handled, False otherwise. """ + if not args: + print_introspection() + return True + if command == "validate": if not args: error("Error: skill name required. Usage: skills validate ") @@ -77,7 +82,11 @@ def validate_skill(skill_metadata): "missing_config": list[str] } """ - return _handler_validate(skill_metadata) + result = _handler_validate(skill_metadata) + json_handler.log_operation("skill_validated", { + "valid": result["valid"], + }) + return result def print_introspection():