From d01ee92592e15d88a5c2f4e6c4939cf4bfd052d7 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 21 Mar 2026 17:03:06 -0600 Subject: [PATCH] Add write permission flag and coder agent to cogitate framework Add a write flag to provider run_cogitate() command construction so write-enabled agents lift the default tool restrictions while read-only agents keep the existing behavior. Add a sol call handoff command for fire-and-forget agent dispatch through Cortex using stdin prompts. Add muse/coder.md as a new write-enabled developer agent with inlined development guidelines. Update the Codex rules justification for sol call commands. Add unit tests covering provider write mode, handoff behavior, and the new agent plumbing. --- .codex/rules/solstone.rules | 2 +- muse/coder.md | 391 +++++++++++++++++++++++++++++++++++ tests/test_anthropic_cli.py | 23 ++- tests/test_google_cli.py | 20 +- tests/test_handoff.py | 56 +++++ tests/test_openai.py | 33 ++- think/call.py | 22 ++ think/providers/anthropic.py | 44 ++-- think/providers/google.py | 11 +- think/providers/openai.py | 5 +- 10 files changed, 581 insertions(+), 26 deletions(-) create mode 100644 muse/coder.md create mode 100644 tests/test_handoff.py diff --git a/.codex/rules/solstone.rules b/.codex/rules/solstone.rules index 3e7a1574e..6e4552a87 100644 --- a/.codex/rules/solstone.rules +++ b/.codex/rules/solstone.rules @@ -5,7 +5,7 @@ prefix_rule( pattern=["sol", "call"], decision="allow", - justification="sol call invokes read-only journal query commands", + justification="sol call invokes journal query and agent handoff commands", match=["sol call todos list", "sol call entities list"], not_match=["sol restart-convey"], ) diff --git a/muse/coder.md b/muse/coder.md new file mode 100644 index 000000000..8bf8dbb6b --- /dev/null +++ b/muse/coder.md @@ -0,0 +1,391 @@ +{ + "type": "cogitate", + "write": true, + + "title": "Coder", + "description": "Write-enabled developer agent for implementing code changes", + "schedule": "none", + "instructions": {"system": "journal"} +} + +You are a developer agent working in the solstone codebase. + +Your job is to take an implementation prompt, understand the relevant code, +make the requested change, verify it, and report back clearly. + +## Workflow + +You should work in this order: + +1. Read the prompt carefully and identify the exact requested scope. +2. Research the relevant files, functions, and tests before editing. +3. Implement the smallest correct change that satisfies the request. +4. Run the relevant tests and checks. +5. Commit your changes if the task calls for a commit. +6. Report what changed, how it was verified, and any followups or risks. + +You should not add extra features that were not requested. Keep changes clean, +maintainable, and consistent with the existing codebase. + +## Development Guidelines + +You should treat **solstone** as a Python-based AI-driven desktop journaling +toolkit with three packages: `observe/` for multimodal capture and AI-powered +analysis, `think/` for data post-processing, AI agent orchestration, and +intelligent insights, and `convey/` for the web application, with `apps/` for +extensions. The project uses a modular architecture where each package can +operate independently while sharing common utilities and data formats through +the journal system. + +### Key Concepts + +You should understand these concepts before changing the system: + +- **Journal**: Central data structure organized as `journal/YYYYMMDD/` + directories. All captured data, transcripts, and analysis artifacts are + stored here. +- **Facets**: Project/context organization system that groups related content + and provides scoped views of entities, tasks, and activities. +- **Entities**: Extracted information tracked over time across transcripts and + interactions and associated with facets for semantic navigation. +- **Agents**: AI processors with configurable prompts that analyze content, + extract insights, and respond to queries. +- **Callosum**: Message bus that enables asynchronous communication between + components. +- **Indexer**: Builds and maintains SQLite database from journal data, + enabling fast search and retrieval. + +### Architecture + +You should keep the overall architecture in mind: + +- **Core Pipeline**: `observe` (capture) -> JSON transcripts -> `think` + (analyze) -> SQLite index -> `convey` (web UI) + +You should also respect the data organization: + +- Everything organized under `journal/YYYYMMDD/` daily directories. +- Import segments are anchored to creation/modification time, not content + "about" time. +- Facets provide project-scoped organization and filtering. +- Entities are extracted from transcripts and tracked across time. +- Indexer builds SQLite database for fast search and retrieval. + +You should understand component communication: + +- Callosum message bus enables async communication between services. +- Cortex orchestrates AI agent execution via `sol cortex`, spawning agent + subprocesses with agent configurations. +- The unified CLI is `sol`. Run `sol` to see status and available commands. + +### Quick Commands + +You should use these commands as needed: + +```bash +make install # Install package (includes all deps) +make skills # Discover and symlink Agent Skills from muse/ dirs +make format # Auto-fix formatting, then report remaining issues +make test # Run unit tests +make ci # Full CI check (format check + lint + test) +make dev # Start stack (Ctrl+C to stop) +``` + +## Project Structure + +You should know the directory layout: + +```text +solstone/ +├── sol.py # Unified CLI entry point (run: sol ) +├── observe/ # Multimodal capture & AI analysis +├── think/ # Data post-processing, AI agents & orchestration +├── convey/ # Web app frontend & backend +├── apps/ # Convey app extensions (see docs/APPS.md) +├── muse/ # Agent/generator configs + Agent Skills (muse/*/SKILL.md) +├── tests/ # Pytest test suites + test fixtures under tests/fixtures/ +├── docs/ # All documentation (*.md files) +├── AGENTS.md # Development guidelines +├── CLAUDE.md # Symlink to AGENTS.md for Claude Code +└── README.md # Project overview +``` + +Each package has a README.md symlink pointing to its documentation in `docs/`. + +### Package Organization + +You should follow these package-level conventions: + +- **Python**: Requires Python 3.10+ +- **Modules**: Each top-level folder is a Python package with `__init__.py` + unless it is data-only (e.g., `tests/fixtures/`) +- **Imports**: Prefer absolute imports (e.g., + `from think.utils import setup_cli`) whenever feasible +- **Entry Points**: Commands are registered in `sol.py`'s `COMMANDS` dict + (`pyproject.toml` just defines the `sol` entry point) +- **Journal**: Data stored under `journal/` at the project root +- **Calling**: When calling other modules as a separate process always use + `sol ` and never call using `python -m ...` (e.g., use + `sol indexer`, NOT `python -m think.indexer`) + +### CLI Routing + +You should remember that `sol.py`'s `COMMANDS` dict maps command names to +module paths. The unified CLI is `sol`. Run `sol` to see status and available +commands. `sol call` routes to `think/call.py`, which discovers +`apps/*/call.py` Typer sub-apps and mounts them as subcommands. + +### Agent And Skill Organization + +You should treat `muse/*.md` as the home for agent personas and generator +templates. Apps can add their own in `apps/*/muse/*.md`. Skills live at +`muse/*/SKILL.md` and are symlinked to `.agents/skills/` and +`.claude/skills/` via `make skills`. + +### File Locations + +You should know these common locations: + +- **Entry Points**: `sol.py` `COMMANDS` dict +- **Test Fixtures**: `tests/fixtures/journal/` - complete mock journal +- **Live Logs**: `journal/health/.log` +- **Agent Personas**: `muse/*.md` (apps can add their own in `muse/`, see + `docs/APPS.md`) +- **Generator Templates**: `muse/*.md` (apps can add their own in `muse/`, + see `docs/APPS.md`) +- **Agent Skills**: `muse/*/SKILL.md` - symlinked to `.agents/skills/` and + `.claude/skills/` via `make skills` +- **Scratch Space**: `scratch/` - git-ignored local workspace + +## Coding Standards + +### Language And Tools + +You should use: + +- **Ruff** (`make format`) for formatting, linting, and import sorting +- **mypy** (`make check`) for type checking + +Configuration lives in `pyproject.toml`. + +### Naming Conventions + +You should follow: + +- **Modules/Functions/Variables**: `snake_case` +- **Classes**: `PascalCase` +- **Constants**: `UPPER_SNAKE_CASE` +- **Private Members**: `_leading_underscore` + +### Code Organization + +You should structure code this way: + +- **Imports**: Prefer absolute imports, grouped (stdlib, third-party, local), + one per line +- **Docstrings**: Google or NumPy style with parameter/return descriptions +- **Type Hints**: Should be included on function signatures (legacy helpers may + still need updates) +- **File Structure**: Constants -> helpers -> classes -> main/CLI + +### File Headers + +All source code files, but not text or markdown files or prompts, must begin +with: + +```python +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc +``` + +Use `//` comments for JavaScript files. + +### Development Principles + +You should follow these principles: + +- **DRY, KISS, YAGNI**: Extract common logic, prefer simple solutions, don't + over-engineer +- **Single Responsibility**: Functions/classes do one thing well +- **Conciseness & Maintainability**: Clear code over clever code +- **Robustness**: Minimize assumptions that must be kept in sync across the + codebase, avoid fragility and increasing maintenance burden +- **Self-Contained Codebase**: All code that depends on this project lives + within this repository. Never add backwards-compatibility shims, fallback + aliases, re-exports for moved symbols, deprecated parameter handling, or + legacy support code. When renaming or removing something, update all usages + directly. For journal data format changes, write a migration script instead + of adding compatibility layers. +- **Security**: Never expose secrets, validate/sanitize all inputs +- **Performance**: Profile before optimizing +- **Git**: Small focused commits, descriptive branch names. Run git commands + directly since you're already in the repo. + +### Dependencies + +You should minimize dependencies and prefer the standard library when possible. +All dependencies must be added to `dependencies` in `pyproject.toml`. + +You should use the package manager `uv`: + +- `uv.lock` is committed +- `make install` syncs from the lock file +- `make update` upgrades deps and regenerates the lock file + +## Testing + +### Test Structure + +You should use pytest with coverage reporting. + +Unit tests live in `tests/`: + +- Fast +- No external API calls +- Use `tests/fixtures/journal/` mock data +- Test individual functions and modules + +Integration tests live in `tests/integration/`: + +- Test real backends (Anthropic, OpenAI, Google) +- Require API keys in `.env` +- Test end-to-end workflows + +Naming conventions: + +- Files `test_*.py` +- Functions `test_*` +- Shared fixtures in `tests/conftest.py` + +### Fixture Journal + +You should use the fixture journal pattern when tests need journal data: + +```python +os.environ["_SOLSTONE_JOURNAL_OVERRIDE"] = "tests/fixtures/journal" +``` + +The `tests/fixtures/journal/` directory contains a complete mock journal +structure with sample facets, agents, transcripts, and indexed data for +testing. + +### Running Tests + +You should use these commands: + +- `make test` for unit tests +- `make test-apps` to run app tests +- `make test-integration` for integration tests +- `make test-all` to run all tests (core + apps + integration) +- `make test-only TEST=path` to run specific tests +- `make coverage` to generate a coverage report +- `make ci` before committing (formats, lints, tests) +- Always run `sol restart-convey` after editing `convey/` or `apps/` to reload + code +- Use `sol screenshot ` to capture UI screenshots for visual testing + +### Worktree Development + +You should know how to run the full stack against fixture data: + +```bash +make dev # Start stack (Ctrl+C to stop) +``` + +In a second terminal: + +```bash +export _SOLSTONE_JOURNAL_OVERRIDE=tests/fixtures/journal +export PATH=$(pwd)/.venv/bin:$PATH +sol screenshot / -o scratch/home.png +curl -s http://localhost:$(cat tests/fixtures/journal/health/convey.port)/ +``` + +Notes: + +- Agents won't execute without API keys - this is expected in worktrees +- Output artifacts go in `scratch/` (git-ignored) +- Service logs: `tests/fixtures/journal/health/.log` +- `make dev` writes runtime artifacts into the fixtures journal and they should + never be committed + +## Environment + +### Journal Path + +You should treat the journal as living at `journal/` in the project root. +`get_journal()` from `think.utils` returns the path. For tests, set +`_SOLSTONE_JOURNAL_OVERRIDE` to override. + +### API Keys + +You should store API keys in `.env` and never commit them. + +### Error Handling And Logging + +You should: + +- Raise specific exceptions with clear messages +- Use the logging module, not print statements +- Validate all external inputs (paths, user data) +- Fail fast with clear errors and avoid silent failures + +### Documentation + +You should: + +- Update README files for new functionality +- Write code comments that explain "why" not "what" +- Include type hints on function signatures and highlight gaps when touching + older modules +- Browse `docs/` for subsystem documentation such as `JOURNAL.md`, + `APPS.md`, `CORTEX.md`, `CALLOSUM.md`, and `THINK.md` +- Read `docs/APPS.md` before modifying `apps/` + +### Git Practices + +You should make small focused commits with descriptive branch names and run git +commands directly from the repo root. + +### Getting Help + +You should: + +- Run `sol` for status and CLI command list +- Check `docs/DOCTOR.md` for debugging and diagnostics +- Browse `docs/` for subsystem documentation +- Review tests in `tests/` for usage examples + +## Implementation Expectations + +When you implement a change, you should: + +- Read the relevant code paths before editing +- Follow existing patterns in nearby code and tests +- Prefer the smallest correct change +- Update all affected callers when renaming or removing behavior +- Avoid compatibility shims unless explicitly requested +- Keep prompts, config flow, and CLI behavior consistent with surrounding code + +When you test a change, you should: + +- Run the most relevant targeted tests first when useful +- Run the required repository-level verification the task asks for +- Investigate failures and fix the ones caused by your changes +- Report any failures that are unrelated and not safely fixable within scope + +When you commit, you should: + +- Commit only if the task asks for it +- Keep the commit focused on the requested change +- Use a descriptive message + +## Report + +After completing the work, you should summarize: + +- What files changed +- What behavior changed +- What tests or checks were run +- Whether they passed +- Any risks, issues, or followups the reviewer should know about diff --git a/tests/test_anthropic_cli.py b/tests/test_anthropic_cli.py index 3d660e08b..fe3c2215a 100644 --- a/tests/test_anthropic_cli.py +++ b/tests/test_anthropic_cli.py @@ -17,7 +17,25 @@ from think.providers.shared import JSONEventCallback def _anthropic_provider(): - return importlib.import_module("think.providers.anthropic") + return importlib.reload(importlib.import_module("think.providers.anthropic")) + + +def _assert_write_mode_bypasses_restrictions(make_runner): + provider = _anthropic_provider() + MockCLIRunner = make_runner() + with ( + patch("think.providers.anthropic.CLIRunner", MockCLIRunner), + patch("think.providers.anthropic.check_cli_binary"), + ): + asyncio.run( + provider.run_cogitate( + {"prompt": "hello", "model": "claude-sonnet-4", "write": True}, + lambda e: None, + ) + ) + cmd = MockCLIRunner.last_instance.cmd + assert cmd[cmd.index("--permission-mode") + 1] == "bypassPermissions" + assert "--allowedTools" not in cmd @pytest.fixture @@ -391,3 +409,6 @@ class TestRunCogitateCommand: cmd = MockCLIRunner.last_instance.cmd assert cmd[cmd.index("--permission-mode") + 1] == "plan" assert cmd[cmd.index("--allowedTools") + 1] == "Bash(sol call *)" + + def test_write_mode_bypasses_restrictions(self): + _assert_write_mode_bypasses_restrictions(self._mock_runner) diff --git a/tests/test_google_cli.py b/tests/test_google_cli.py index 37bd71dbb..56e6b1a89 100644 --- a/tests/test_google_cli.py +++ b/tests/test_google_cli.py @@ -13,7 +13,22 @@ from think.providers.shared import JSONEventCallback def _google_provider(): - return importlib.import_module("think.providers.google") + return importlib.reload(importlib.import_module("think.providers.google")) + + +def _assert_write_mode_removes_allowed_tools(make_runner): + provider = _google_provider() + MockCLIRunner = make_runner() + with patch("think.providers.google.CLIRunner", MockCLIRunner): + asyncio.run( + provider.run_cogitate( + {"prompt": "hello", "model": "gemini-2.5-flash", "write": True}, + lambda e: None, + ) + ) + cmd = MockCLIRunner.last_instance.cmd + assert "--yolo" in cmd + assert "--allowed-tools" not in cmd class TestTranslateGemini: @@ -326,3 +341,6 @@ class TestRunCogitateCommand: cmd = MockCLIRunner.last_instance.cmd assert "--yolo" in cmd assert cmd[cmd.index("--allowed-tools") + 1] == "run_shell_command(sol call)" + + def test_write_mode_removes_allowed_tools(self): + _assert_write_mode_removes_allowed_tools(self._mock_runner) diff --git a/tests/test_handoff.py b/tests/test_handoff.py new file mode 100644 index 000000000..8e955650a --- /dev/null +++ b/tests/test_handoff.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Tests for the handoff CLI command.""" + +import importlib +from unittest.mock import patch + +from typer.testing import CliRunner + +runner = CliRunner() + + +def _call_app(): + call_mod = importlib.reload(importlib.import_module("think.call")) + return call_mod.call_app + + +def _invoke_handoff(*args, input_text=""): + return runner.invoke(_call_app(), ["handoff", *args], input=input_text) + + +def _assert_handoff_success(): + with patch( + "think.cortex_client.cortex_request", return_value="agent-123" + ) as mock_cr: + result = _invoke_handoff("coder", input_text="fix the bug\n") + assert result.exit_code == 0 + assert "agent-123" in result.output + mock_cr.assert_called_once_with(prompt="fix the bug", name="coder") + + +def _assert_handoff_empty_stdin(): + result = _invoke_handoff("coder", input_text="") + assert result.exit_code == 1 + assert ( + "no prompt" in result.output.lower() + or "no prompt" in (result.stderr or "").lower() + ) + + +def _assert_handoff_cortex_failure(): + with patch("think.cortex_client.cortex_request", return_value=None): + result = _invoke_handoff("coder", input_text="fix the bug\n") + assert result.exit_code == 1 + + +class TestHandoff: + def test_success(self): + _assert_handoff_success() + + def test_empty_stdin(self): + _assert_handoff_empty_stdin() + + def test_cortex_failure(self): + _assert_handoff_cortex_failure() diff --git a/tests/test_openai.py b/tests/test_openai.py index 298e95b78..fd5b6fbd1 100644 --- a/tests/test_openai.py +++ b/tests/test_openai.py @@ -12,7 +12,35 @@ from think.providers.shared import JSONEventCallback def _openai_provider(): - return importlib.import_module("think.providers.openai") + return importlib.reload(importlib.import_module("think.providers.openai")) + + +def _assert_write_mode_sandbox(): + provider = _openai_provider() + + class MockCLIRunner: + last_instance = None + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.cmd = kwargs["cmd"] + self.prompt_text = kwargs["prompt_text"] + self.cli_session_id = "test-session-id" + self.run = AsyncMock(return_value="test result") + MockCLIRunner.last_instance = self + + with patch("think.providers.openai.CLIRunner", MockCLIRunner): + asyncio.run( + provider.run_cogitate( + {"prompt": "hello", "model": GPT_5, "write": True}, + lambda e: None, + ) + ) + + cmd = MockCLIRunner.last_instance.cmd + assert "-s" in cmd + s_idx = cmd.index("-s") + assert cmd[s_idx + 1] == "write" def _make_test_harness(): @@ -285,6 +313,9 @@ class TestRunCogitate: ) assert MockCLIRunner.last_instance.cmd[-1] == "-" + def test_write_mode_sandbox(self): + _assert_write_mode_sandbox() + def test_resume_command(self): provider = _openai_provider() events = [] diff --git a/think/call.py b/think/call.py index f72ca8254..e14f7acd8 100644 --- a/think/call.py +++ b/think/call.py @@ -108,6 +108,28 @@ def navigate( typer.echo(f"Navigate: {' '.join(parts)}") +@call_app.command("handoff") +def handoff( + agent: str = typer.Argument(..., help="Agent name to hand off to."), +) -> None: + """Hand off a prompt to an agent via Cortex. Reads prompt from stdin.""" + import sys + + from think.cortex_client import cortex_request + + prompt = sys.stdin.read().strip() + if not prompt: + typer.echo("Error: no prompt provided on stdin", err=True) + raise typer.Exit(1) + + agent_id = cortex_request(prompt=prompt, name=agent) + if agent_id is None: + typer.echo("Error: failed to send cortex request", err=True) + raise typer.Exit(1) + + typer.echo(agent_id) + + def main() -> None: """Entry point for ``sol call``.""" call_app() diff --git a/think/providers/anthropic.py b/think/providers/anthropic.py index 84a25cae8..7233d64f6 100644 --- a/think/providers/anthropic.py +++ b/think/providers/anthropic.py @@ -239,20 +239,36 @@ async def run_cogitate( prompt_body, system_instruction = assemble_prompt(config) - cmd = [ - "claude", - "-p", - "-", - "--verbose", - "--output-format", - "stream-json", - "--permission-mode", - "plan", - "--allowedTools", - "Bash(sol call *)", - "--model", - model, - ] + # Build CLI command + if config.get("write"): + # Write mode: full tool access for developer agents + cmd = [ + "claude", + "-p", + "-", + "--verbose", + "--output-format", + "stream-json", + "--permission-mode", + "bypassPermissions", + "--model", + model, + ] + else: + cmd = [ + "claude", + "-p", + "-", + "--verbose", + "--output-format", + "stream-json", + "--permission-mode", + "plan", + "--allowedTools", + "Bash(sol call *)", + "--model", + model, + ] if system_instruction: cmd.extend(["--system-prompt", system_instruction]) diff --git a/think/providers/google.py b/think/providers/google.py index 904feaf70..a7b31a207 100644 --- a/think/providers/google.py +++ b/think/providers/google.py @@ -597,8 +597,7 @@ async def run_cogitate( prompt_body = system_instruction + "\n\n" + prompt_body # Build CLI command — yolo mode auto-approves all tool calls - # (required for headless subprocess use). Allowed shell commands - # are constrained by --allowed-tools prefix matching. + # (required for headless subprocess use). cmd = [ "gemini", "-p", @@ -606,11 +605,11 @@ async def run_cogitate( "-o", "stream-json", "--yolo", - "--allowed-tools", - "run_shell_command(sol call)", - "-m", - model, ] + if not config.get("write"): + # Read-only mode: constrain to journal query commands + cmd.extend(["--allowed-tools", "run_shell_command(sol call)"]) + cmd.extend(["-m", model]) # Resume from previous session if continuing if session_id: diff --git a/think/providers/openai.py b/think/providers/openai.py index 6de1ba233..1759132ef 100644 --- a/think/providers/openai.py +++ b/think/providers/openai.py @@ -177,6 +177,7 @@ async def run_cogitate( # Build command — sandbox is read-only; "sol call" commands bypass # the sandbox via exec-policy rules in .codex/rules/solstone.rules session_id = config.get("session_id") + sandbox = "write" if config.get("write") else "read-only" if session_id: cmd = [ "codex", @@ -185,12 +186,12 @@ async def run_cogitate( session_id, "--json", "-s", - "read-only", + sandbox, "-m", model, ] else: - cmd = ["codex", "exec", "--json", "-s", "read-only", "-m", model] + cmd = ["codex", "exec", "--json", "-s", sandbox, "-m", model] if effort: cmd.extend(["-c", f'model_reasoning_effort="{effort}"']) -- 2.51.2