diff --git a/codec_agent_plan.py b/codec_agent_plan.py index c9a5357..d2c5afe 100644 --- a/codec_agent_plan.py +++ b/codec_agent_plan.py @@ -59,12 +59,20 @@ _CODEC_DIR = Path(os.path.expanduser("~/.codec")) _AGENTS_DIR = _CODEC_DIR / "agents" _GLOBAL_GRANTS_PATH = _CODEC_DIR / "agent_global_grants.json" +# Phase 3.5: human-browseable project folder root (Claude Code-style). +# Each agent gets ~/codec-projects// on creation, openable +# in any IDE. Override via ~/.codec/config.json:agents.project_root_dir +# or env CODEC_PROJECT_ROOT_DIR. +_PROJECT_ROOT = Path(os.path.expanduser( + os.environ.get("CODEC_PROJECT_ROOT_DIR", "") or "~/codec-projects" +)) # ── Schema constants ────────────────────────────────────────────────────────── PLAN_SCHEMA_VERSION = 1 GLOBAL_GRANTS_SCHEMA_VERSION = 1 DEFAULT_STEP_BUDGET_PER_CHECKPOINT = 30 MAX_CLARIFYING_ROUNDS = 3 +MAX_PROJECT_SLUG_LEN = 50 # ── Dataclasses ─────────────────────────────────────────────────────────────── @@ -302,10 +310,15 @@ def _qwen_chat(user_prompt: str, system_prompt: str = "", def draft_plan(agent_id: str, description: str, registry=None, - available_skills: Optional[List[str]] = None) -> Plan: + available_skills: Optional[List[str]] = None, + project_dir: Optional[Path] = None) -> Plan: """Call Qwen-3.6 with the project description, parse response into Plan, validate against skill registry. Raises PlanValidationError on schema or - validation failure; QwenUnavailableError on LLM unavailability.""" + validation failure; QwenUnavailableError on LLM unavailability. + + Phase 3.5: when `project_dir` is provided, the LLM is told to default + write_paths to that folder so files the agent creates land somewhere + the user can open in an IDE.""" if registry is None: try: from codec_dispatch import registry as _reg @@ -316,9 +329,20 @@ def draft_plan(agent_id: str, description: str, registry=None, if available_skills is None: available_skills = sorted(registry.names() or []) + project_hint = "" + if project_dir is not None: + project_hint = ( + f"\nProject folder: {project_dir}\n" + f"Default write_paths for this agent: \"{project_dir}/**\". " + f"Files the user will open in their IDE land here. " + f"Don't write outside this folder unless the project description " + f"explicitly requires it.\n" + ) + user_prompt = ( f"agent_id: {agent_id}\n\n" - f"Available skills (registry): {', '.join(available_skills)}\n\n" + f"Available skills (registry): {', '.join(available_skills)}\n" + f"{project_hint}\n" f"Project description:\n{description}\n\n" f"Generate the JSON plan now." ) @@ -393,16 +417,21 @@ def _ask_user(question: str, *, agent_id: str, def draft_plan_with_clarification(agent_id: str, description: str, registry=None, - max_rounds: int = MAX_CLARIFYING_ROUNDS) -> Plan: + max_rounds: int = MAX_CLARIFYING_ROUNDS, + project_dir: Optional[Path] = None) -> Plan: """Wrap draft_plan with a clarifying-question loop. If LLM returns {"too_vague": True, "clarifying_questions": [...]}, ask user via codec_ask_user.ask, append answers to description, retry. After - max_rounds without convergence, raise DescriptionTooVagueError.""" + max_rounds without convergence, raise DescriptionTooVagueError. + + Phase 3.5: `project_dir` (when provided) is forwarded to draft_plan + so the LLM defaults write_paths to the human-browseable folder.""" enriched_description = description for round_idx in range(max_rounds + 1): try: - return draft_plan(agent_id, enriched_description, registry=registry) + return draft_plan(agent_id, enriched_description, registry=registry, + project_dir=project_dir) except PlanValidationError as e: # Check if this was a "too_vague" response (sentinel from LLM) if "too_vague" in str(e).lower(): @@ -554,6 +583,56 @@ def _audit(event: str, source: str, message: str = "", level=level, extra=dict(extra or {})) +# ── Project folder (Phase 3.5: Claude Code-style human-browseable dir) ──────── +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _slugify(title: str) -> str: + """Convert a title to a filesystem-safe slug. + "Build a Telegram bot for Marbella property!" → "build-a-telegram-bot-for-marbella-property". + Falls back to "project" if the title has no alphanumeric characters.""" + s = _SLUG_RE.sub("-", (title or "").lower()).strip("-") + if not s: + s = "project" + return s[:MAX_PROJECT_SLUG_LEN].rstrip("-") or "project" + + +def _project_root() -> Path: + """Return the configured project root, with config.json override. + Resolved at call time so tests can monkeypatch _PROJECT_ROOT or set + CODEC_PROJECT_ROOT_DIR via monkeypatch.setenv.""" + # config.json override (~/.codec/config.json:agents.project_root_dir) + cfg = _CODEC_DIR / "config.json" + if cfg.exists(): + try: + data = json.loads(cfg.read_text()) + override = (data.get("agents") or {}).get("project_root_dir") + if override: + return Path(os.path.expanduser(override)) + except Exception: + pass + return _PROJECT_ROOT + + +def create_project_folder(title: str, agent_id: str) -> Path: + """Create a human-browseable project folder for this agent. + Returns absolute Path. Disambiguates if the slug already exists.""" + root = _project_root() + root.mkdir(parents=True, exist_ok=True) + base_slug = _slugify(title) + candidate = root / base_slug + suffix = 2 + while candidate.exists(): + candidate = root / f"{base_slug}-{suffix}" + suffix += 1 + if suffix > 99: + # Pathological case: 99 collisions → fall back to agent_id suffix + candidate = root / f"{base_slug}-{agent_id}" + break + candidate.mkdir(parents=True, exist_ok=False) + return candidate.resolve() + + # ── Public orchestrator ─────────────────────────────────────────────────────── def _new_agent_id() -> str: return f"agent_{secrets.token_hex(6)}" @@ -564,10 +643,24 @@ def create_agent(title: str, description: str, notification_channels: Optional[List[str]] = None) -> str: """Top-level entry point. Drafts a plan, persists to disk, emits audit. Returns the new agent_id. Status after this call: awaiting_approval - (or plan_failed on validation error).""" + (or plan_failed on validation error). + + Phase 3.5: ALSO creates a human-browseable project folder under + ~/codec-projects// (or the configured project_root_dir). The + plan's permission_manifest.write_paths defaults to this folder, so + files the agent creates land where the user can open them in an IDE. + """ agent_id = _new_agent_id() cid = secrets.token_hex(6) + # Phase 3.5: create the human-browseable project folder up-front so + # the plan-drafter can reference it as the default write_paths root. + try: + project_dir = create_project_folder(title, agent_id) + except Exception as e: + log.warning("[%s] project folder creation failed: %s", agent_id, e) + project_dir = None + # Initial manifest manifest = { "agent_id": agent_id, @@ -576,13 +669,15 @@ def create_agent(title: str, description: str, "created_at": _now_iso(), "updated_at": _now_iso(), "notification_channels": notification_channels or ["pwa"], + "project_dir": str(project_dir) if project_dir else None, } save_manifest(agent_id, manifest) save_state(agent_id, {"current_checkpoint": 0}) # Draft plan (with clarification loop) try: - plan = draft_plan_with_clarification(agent_id, description, registry=registry) + plan = draft_plan_with_clarification(agent_id, description, registry=registry, + project_dir=project_dir) except DescriptionTooVagueError as e: set_status(agent_id, "plan_failed", reason=f"too_vague: {e}") _audit(AGENT_PLAN_REJECTED, "codec-agent-plan", diff --git a/codec_chat.html b/codec_chat.html index ee43318..dccee96 100644 --- a/codec_chat.html +++ b/codec_chat.html @@ -798,9 +798,18 @@

CODEC

pendingDiv.querySelector('.msg-bubble').innerHTML='Plan failed: '+escHtml(pd.detail); chatHist.push({role:'assistant',content:'Plan failed: '+pd.detail}); }else if(pd.agent_id){ + var folderHtml=''; + if(pd.project_dir){ + folderHtml='
'+ + '
Project folder created
'+ + ''+escHtml(pd.project_dir)+''+ + '
Open this folder in your IDE to see the agent\'s files as they\'re created. (cmd+click to open in Finder)
'+ + '
'; + } var html='
Project drafted
'+ '
agent_id: '+escHtml(pd.agent_id)+'
'+ - '
A plan with a permission manifest has been written to ~/.codec/agents/'+escHtml(pd.agent_id)+'/plan.json. Review and approve to start.
'+ + folderHtml+ + '
A plan with a permission manifest has been written. Review and approve to let the agent run autonomously.
'+ '
'+ ''+ ''+ diff --git a/routes/agents.py b/routes/agents.py index dd58cc4..79fab48 100644 --- a/routes/agents.py +++ b/routes/agents.py @@ -272,7 +272,11 @@ def create_agent(body: CreateAgentBody): raise HTTPException(status_code=503, detail=f"Qwen-3.6 unavailable: {e}") manifest = _cap.load_manifest(agent_id) - return {"agent_id": agent_id, "status": manifest.get("status", "unknown")} + return { + "agent_id": agent_id, + "status": manifest.get("status", "unknown"), + "project_dir": manifest.get("project_dir"), # Phase 3.5: human-browseable folder + } @router.get("/api/agents") diff --git a/tests/test_agent_plan.py b/tests/test_agent_plan.py index 4fcfe0c..bc33490 100644 --- a/tests/test_agent_plan.py +++ b/tests/test_agent_plan.py @@ -121,6 +121,9 @@ def temp_codec_dir(tmp_path, monkeypatch): monkeypatch.setattr(cap, "_CODEC_DIR", tmp_path) monkeypatch.setattr(cap, "_AGENTS_DIR", tmp_path / "agents") monkeypatch.setattr(cap, "_GLOBAL_GRANTS_PATH", tmp_path / "agent_global_grants.json") + # Phase 3.5: redirect project root so create_project_folder doesn't + # touch the real ~/codec-projects/ during tests. + monkeypatch.setattr(cap, "_PROJECT_ROOT", tmp_path / "codec-projects") # Test-pollution fix: redirect codec_audit._AUDIT_LOG so audit emits # during plan creation/approval do NOT leak into production ~/.codec/audit.log. monkeypatch.setattr(codec_audit, "_AUDIT_LOG", tmp_path / "audit.log") @@ -523,7 +526,7 @@ def test_post_api_agents_creates_drafts(monkeypatch, temp_codec_dir, tmp_path): "estimated_duration_minutes": 5, "assumptions": []})) fake_reg = MagicMock(); fake_reg.names.return_value = ["weather"] monkeypatch.setattr("codec_agent_plan.draft_plan_with_clarification", - lambda agent_id, desc, registry=None, max_rounds=3: + lambda agent_id, desc, registry=None, max_rounds=3, project_dir=None: cap.draft_plan(agent_id, desc, registry=fake_reg)) from routes.agents import router @@ -790,3 +793,120 @@ def fake_audit(event, source, message="", correlation_id="", **kw): events = [e for e, _ in audit_emits] assert "agent_plan_drafted" in events assert "agent_plan_approved" in events + + +# ───────────────────────────────────────────────────────────────────────────── +# Phase 3.5 — Project folder creation (Claude Code-style human-browseable dir) +# ───────────────────────────────────────────────────────────────────────────── + +def test_slugify_basic(): + from codec_agent_plan import _slugify + assert _slugify("Build a Telegram bot") == "build-a-telegram-bot" + assert _slugify("Marbella Property Bot!!!") == "marbella-property-bot" + assert _slugify("EUR/USD vol monitor") == "eur-usd-vol-monitor" + + +def test_slugify_handles_unicode_and_empty(): + from codec_agent_plan import _slugify + # Unicode and special chars stripped, ascii-only slug + assert _slugify("Café résumé") == "caf-r-sum" + # Pure non-alphanumeric falls back to "project" + assert _slugify("!!!") == "project" + assert _slugify("") == "project" + + +def test_slugify_truncates_long_titles(): + from codec_agent_plan import _slugify, MAX_PROJECT_SLUG_LEN + long_title = "build " + "a-very-long-project-title-" * 10 + slug = _slugify(long_title) + assert len(slug) <= MAX_PROJECT_SLUG_LEN + assert not slug.endswith("-") # trailing dash trimmed + + +def test_create_project_folder_creates_dir(temp_codec_dir): + from codec_agent_plan import create_project_folder + folder = create_project_folder("Marbella property bot", "agent_abc123") + assert folder.exists() and folder.is_dir() + assert folder.name == "marbella-property-bot" + assert folder.parent == (temp_codec_dir / "codec-projects").resolve() + + +def test_create_project_folder_disambiguates_collisions(temp_codec_dir): + from codec_agent_plan import create_project_folder + a = create_project_folder("Property bot", "agent_a") + b = create_project_folder("Property bot", "agent_b") + c = create_project_folder("Property bot", "agent_c") + assert a.name == "property-bot" + assert b.name == "property-bot-2" + assert c.name == "property-bot-3" + assert a.exists() and b.exists() and c.exists() + + +def test_create_agent_writes_project_dir_in_manifest(monkeypatch, temp_codec_dir): + """create_agent populates manifest.project_dir with the resolved path.""" + import codec_agent_plan as cap + monkeypatch.setattr(cap, "_qwen_chat", lambda *a, **k: json.dumps({ + "goals": ["g"], + "checkpoints": [{"title": "t", "description": "d", + "skills_needed": ["weather"], + "expected_output": "o", "step_budget": 10}], + "permission_manifest": {"read_paths": [], "write_paths": [], + "network_domains": [], "skills": ["weather"], + "destructive_ops": []}, + "estimated_duration_minutes": 5, "assumptions": [], + })) + fake_reg = MagicMock() + fake_reg.names.return_value = ["weather"] + monkeypatch.setattr("codec_dispatch.registry", fake_reg, raising=False) + + agent_id = cap.create_agent(title="Property bot for Marbella", + description="Build a property bot") + + m = cap.load_manifest(agent_id) + assert m.get("project_dir"), "manifest must include project_dir after create_agent" + pdir = Path(m["project_dir"]) + assert pdir.exists() and pdir.is_dir() + assert pdir.name == "property-bot-for-marbella" + # The project folder lives under the configured root (tmp during tests) + assert (temp_codec_dir / "codec-projects").resolve() in pdir.parents + + +def test_draft_plan_includes_project_dir_in_qwen_prompt(monkeypatch, temp_codec_dir): + """When project_dir is passed to draft_plan, the user_prompt sent to Qwen + must include the path so the LLM defaults write_paths to it.""" + import codec_agent_plan as cap + captured = {"prompt": None} + def capture(user_prompt, system_prompt, max_tokens=4000): + captured["prompt"] = user_prompt + return json.dumps({ + "goals": ["g"], "checkpoints": [{"title": "t", "description": "d", + "skills_needed": ["weather"], "expected_output": "o", "step_budget": 10}], + "permission_manifest": {"read_paths": [], "write_paths": [], + "network_domains": [], "skills": ["weather"], "destructive_ops": []}, + "estimated_duration_minutes": 5, "assumptions": []}) + monkeypatch.setattr(cap, "_qwen_chat", capture) + fake_reg = MagicMock() + fake_reg.names.return_value = ["weather"] + + pdir = temp_codec_dir / "codec-projects" / "test-project" + pdir.mkdir(parents=True) + cap.draft_plan(agent_id="agent_x", description="build x", + registry=fake_reg, project_dir=pdir) + + assert captured["prompt"] is not None + assert str(pdir) in captured["prompt"] + assert "Project folder" in captured["prompt"] + assert "write_paths" in captured["prompt"] + + +def test_project_root_config_override(temp_codec_dir, monkeypatch): + """~/.codec/config.json:agents.project_root_dir overrides the env default.""" + import codec_agent_plan as cap + custom = temp_codec_dir / "my-custom-projects" + cfg_path = temp_codec_dir / "config.json" + cfg_path.write_text(json.dumps({ + "agents": {"project_root_dir": str(custom)} + })) + # _project_root() reads ~/.codec/config.json; _CODEC_DIR is patched to tmp_path + folder = cap.create_project_folder("Custom test", "agent_x") + assert custom in folder.parents or folder.parent == custom.resolve()