From 23ea30145af03eea0e52be4694c3d88eea7927ea Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 12 Jun 2026 18:37:11 -0600 Subject: [PATCH] refactor(skills): collapse installed agent skills into two routers (sol, journal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Project install now installs exactly two router skills — `sol` and `journal`; app talent fragments are builder source, not installed skills. - New `sol skills build [--check]` generates deterministic, checked-in router references (solstone/talent/{sol,journal}/references/commands.md) from app fragments; `--check` is read-only and wired into install-checks/make ci via `check-skill-references`; `make skills` converges references then links. - New shared `solstone/think/command_polarity.py` (READ_VERBS/WRITE_VERBS/classify_verb) is the single polarity oracle; check_layer_hygiene imports it. - Removed the `vit` skill. - `journal start` is now runtime-only — dropped the .last-start-version self-heal (wrapper/unit/skill refresh); setup/install/doctor own convergence now. - Added examine-only advisory `skill_state` doctor check (sol + journal doctor) that warns on missing/stale/foreign router links and names the repair command. - User-wide install now installs the `sol` skill; an existing user-wide `solstone` bundle is left untouched. - Updated docs (SOLCLI, APPS, project-structure, CONTRIBUTING, INSTALL, AGENTS) and the journal template to the two-router contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 +- CONTRIBUTING.md | 10 +- INSTALL.md | 4 +- Makefile | 8 + docs/APPS.md | 38 ++- docs/SOLCLI.md | 49 ++-- docs/project-structure.md | 10 +- journal/AGENTS.md | 2 +- scripts/check_journal_io_access.py | 2 - scripts/check_journal_io_mechanic.py | 3 +- scripts/check_layer_hygiene.py | 36 +-- solstone/talent/journal/SKILL.md | 1 + .../talent/journal/references/commands.md | 11 + solstone/talent/{solstone => sol}/SKILL.md | 89 +++--- solstone/talent/sol/references/commands.md | 71 +++++ solstone/talent/vit/SKILL.md | 92 ------ solstone/think/command_polarity.py | 84 ++++++ solstone/think/doctor.py | 79 ++++- solstone/think/setup.py | 8 +- solstone/think/skills_build.py | 272 ++++++++++++++++++ solstone/think/skills_cli.py | 81 ++++-- solstone/think/start.py | 60 ---- tests/test_doctor.py | 3 + tests/test_journal_doctor.py | 78 +++++ tests/test_journal_skill.py | 1 + tests/test_journal_start.py | 196 +------------ tests/test_setup.py | 8 +- tests/test_skills_build.py | 166 +++++++++++ tests/test_skills_cli.py | 166 +++++++---- tests/test_sol_service_hard_error.py | 5 +- 30 files changed, 1085 insertions(+), 554 deletions(-) create mode 100644 solstone/talent/journal/references/commands.md rename solstone/talent/{solstone => sol}/SKILL.md (53%) create mode 100644 solstone/talent/sol/references/commands.md delete mode 100644 solstone/talent/vit/SKILL.md create mode 100644 solstone/think/command_polarity.py create mode 100644 solstone/think/skills_build.py create mode 100644 tests/test_skills_build.py diff --git a/AGENTS.md b/AGENTS.md index 09918c180..dad6bf55a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This file is the **developer guide** for the solstone repository. Read it before Audience: - **Coders** (cwd = repo root, editing `solstone/observe/`, `solstone/think/`, `solstone/convey/`, `solstone/apps/`, `solstone/talent/`, `tests/`) — you're in the right place. -- **Cogitate talents** (cwd = `journal/`, running inside the live system) — your entry is `solstone/talent/journal/SKILL.md`, installed into `journal/.claude/skills/journal/` and `journal/.agents/skills/journal/`. The runtime contract you operate under — tools, reads vs writes, finalization, access tiers, and what is *not* in your context — is `docs/COGITATE.md`. +- **Cogitate talents** (cwd = `journal/`, running inside the live system) — your journal-side entry is `solstone/talent/journal/SKILL.md`, installed into `journal/.claude/skills/journal/` and `journal/.agents/skills/journal/` alongside the `sol` router skill. The runtime contract you operate under — tools, reads vs writes, finalization, access tiers, and what is *not* in your context — is `docs/COGITATE.md`. - **Operators** debugging a running system — see `docs/DOCTOR.md`. For the journal-side runtime entry point, see `journal/AGENTS.md`. @@ -38,7 +38,7 @@ Read, in order, when you enter the repo for a coding task: | `solstone/think/` | Post-processing core — cortex, talent, callosum, indexer, entities, facets, activities, scheduler, heartbeat, supervisor | anything downstream of capture; most coder work lives here | `docs/THINK.md`, `docs/CORTEX.md`, `docs/COGITATE.md`, `docs/CALLOSUM.md` | | `solstone/convey/` | Web app framework — app discovery, routing, bridge | layout / framework-level UI changes | `docs/CONVEY.md` | | `solstone/apps/` | Convey apps — each self-contained (`call.py` Typer sub-app + `routes.py` + `templates/`) | adding a user-facing feature, a `sol call ` verb, a UI surface | `docs/APPS.md` (required reading before modifying `solstone/apps/`) | -| `solstone/talent/` | AI talent configs (markdown prompts + optional `.py` post-hooks) + `SKILL.md`s (journal, partner, …) | defining or tuning a talent; adding a journal-side skill | `solstone/talent/journal/SKILL.md`, `docs/PROMPT_TEMPLATES.md` | +| `solstone/talent/` | AI talent configs (markdown prompts + optional `.py` post-hooks) + installed router skills (`sol`, `journal`); app fragments feed generated router references | defining or tuning a talent; updating router guidance | `solstone/talent/journal/SKILL.md`, `docs/PROMPT_TEMPLATES.md` | | `scripts/` | Repo maintenance scripts — `check_layer_hygiene.py` | tooling that guards the codebase; wired into `make ci` | (none) | | `tests/` | Pytest suites + `tests/fixtures/journal/` mock journal | writing tests; debugging flakiness; `make dev` / `make sandbox` use fixtures as the journal | `docs/testing.md` | | `docs/` | All longform documentation | reference lookups; never your first stop | §10 below | @@ -88,7 +88,7 @@ Verified against `Makefile`. Grouped by use. | Target | When to use | |--------|-------------| | `make install` | First setup and whenever `pyproject.toml` or `uv.lock` changes. Creates `.venv/`, syncs deps, runs `make skills`. | -| `make skills` | After adding or renaming a `SKILL.md` under `solstone/talent/` or `solstone/apps/*/talent/`. Rewrites the `.claude/` + `.agents/` skill symlinks into `journal/`. (`make install` depends on this; rarely run alone.) | +| `make skills` | Regenerate generated router references, then rewrite the `sol` + `journal` router skill symlinks into `journal/`. (`make install` depends on this; rarely run alone.) | | `make update` | Upgrade all deps to latest, regenerate `uv.lock`. Expect test churn. | | `make update-prices` | Refresh genai-prices model-cost data when adding a new provider model or when pricing tests fail. | | `make clean` | Remove build artifacts, caches, and the skill symlinks. Does not touch `.venv/`. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ceac59320..880aa97ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,9 +53,9 @@ make install .venv/bin/journal setup ``` -`make install` creates `.venv/`, syncs dependencies from `pyproject.toml` and `uv.lock`, installs the package in editable mode, and refreshes the project skill symlinks into the journal. +`make install` creates `.venv/`, syncs dependencies from `pyproject.toml` and `uv.lock`, installs the package in editable mode, regenerates router skill references, and refreshes the `sol` + `journal` project skill symlinks into the journal. -`.venv/bin/journal setup` runs doctor diagnostics, confirms the journal path, installs local transcription models, installs the `solstone` skill for Claude Code when Claude is configured, creates or refreshes the source-checkout wrappers at `~/.local/bin/sol` and `~/.local/bin/journal`, and starts the background service. The default web interface listens on http://localhost:5015. Use `.venv/bin/journal setup --port 8000` to choose another port on the first run. +`.venv/bin/journal setup` runs doctor diagnostics, confirms the journal path, installs local transcription models, installs the `sol` user skill for Claude Code / Codex / Gemini when those agents are configured, installs the `sol` + `journal` router skills into the journal, creates or refreshes the source-checkout wrappers at `~/.local/bin/sol` and `~/.local/bin/journal`, and starts the background service. The default web interface listens on http://localhost:5015. Use `.venv/bin/journal setup --port 8000` to choose another port on the first run. After the first setup run, the wrapper lets you use `sol` from anywhere: @@ -156,13 +156,15 @@ If you change the helper source, rebuild it before testing the CoreML parakeet p Talent prompts live under `solstone/talent/.md`; apps may add app-specific talent files under `solstone/apps//talent/`. Talent frontmatter declares type, schedule, provider/model behavior, hooks, priority, and output expectations. -Skills are `SKILL.md` files under `solstone/talent/` or `solstone/apps/*/talent/`. After adding or renaming a skill, run: +The installed project skills are the two router skills under `solstone/talent/sol/` and `solstone/talent/journal/`. App command fragments under `solstone/apps//talent//SKILL.md` feed the generated router references; they are not installed as top-level skills. + +After changing a router skill or an app command fragment, run: ```bash make skills ``` -That refreshes the `.claude/` and `.agents/` skill symlinks inside the journal. `make install` also runs this target. +That target first runs `sol skills build` to regenerate the checked-in references, then refreshes the `sol` + `journal` router skill symlinks inside the journal. `make install` also runs this target, and `make ci` / `make install-checks` runs `sol skills build --check` to catch stale generated references. ## Migrating from a source install to a packaged install diff --git a/INSTALL.md b/INSTALL.md index bdf9cd98a..d250d7a9f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -39,7 +39,7 @@ uv tool install solstone journal setup ``` -this runs the setup readiness doctor battery, confirms the journal directory at `~/journal`, installs the local transcription model (~2.5 GB on linux), installs the solstone skill for claude code, codex, and gemini, installs all journal-side talent skills into the configured journal so cogitate sub-agents can discover them, and starts a background service (systemd on linux, launchd on macOS) listening on http://localhost:5015. +this runs the setup readiness doctor battery, confirms the journal directory at `~/journal`, installs the local transcription model (~2.5 GB on linux), installs the `sol` skill for claude code, codex, and gemini, installs the journal-side `sol` and `journal` router skills so sol can tend the journal, and starts a background service (systemd on linux, launchd on macOS) listening on http://localhost:5015. let your human know: **open http://localhost:5015 in a browser**. the first-run wizard walks them through setting their identity and connecting a gemini API key. network access, and the password it requires, can be configured later in settings → security. @@ -97,7 +97,7 @@ uv tool upgrade solstone && journal setup 1. remove setup-managed runtime files: `journal setup --clean-uninstall` this removes the user service, managed `~/.local/bin/sol` wrapper, user config, and setup manifest. it does not remove your journal. -2. optional: remove agentic-tooling skills: `sol skills uninstall`. +2. optional: remove the installed `sol` agent skill: `sol skills uninstall`. 3. uninstall the python package: `uv tool uninstall solstone` (or `pipx uninstall solstone`). 4. macOS only: drag `/Applications/solstone.app` to Trash. 5. macOS only, optional: remove observer app data and the parakeet model cache: diff --git a/Makefile b/Makefile index 0745c09f6..add8ca027 100644 --- a/Makefile +++ b/Makefile @@ -127,6 +127,7 @@ preflight: # Setup skill symlinks skills: + @$(VENV_BIN)/sol skills build @$(VENV_BIN)/sol skills install --project journal --agent all # Start local dev stack against fixture journal (no observers, no daily processing) @@ -403,6 +404,9 @@ install-checks: .installed @echo "=== Running cogitate-prompt check ===" @$(MAKE) check-cogitate-prompts @echo "" + @echo "=== Checking generated skill references ===" + @$(MAKE) check-skill-references + @echo "" @echo "=== Checking extras consistency ===" @$(VENV_BIN)/python scripts/check_extras_consistency.py @echo "" @@ -489,6 +493,10 @@ check-tools-http-only: .installed check-cogitate-prompts: .installed $(VENV_BIN)/python scripts/check_cogitate_prompts.py +# Generated router skill references gate +check-skill-references: .installed + $(VENV_BIN)/sol skills build --check + # Re-run the live four-backend integrated-façade cogitate smoke. Spawns the # archived runner (extro `vpe/workspace/archived/`) against this venv so the # real openhands-sdk Agent path is exercised end-to-end. Requires real API diff --git a/docs/APPS.md b/docs/APPS.md index fa93ca044..5edb0fdef 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -408,31 +408,30 @@ Context is provided inline in the `.md` body via template variables: --- -### 10. `solstone/talent/` - Agent Skills +### 10. Router Skills and App Command Fragments -Define [Agent Skills](https://agentskills.io/specification) as subdirectories within `solstone/talent/`. Skills package procedural knowledge, workflows, and resources that AI coding agents (Claude Code, GitHub Copilot, Gemini CLI, etc.) can discover and use on demand. +Project installs expose exactly two [Agent Skills](https://agentskills.io/specification): `solstone/talent/sol/` and `solstone/talent/journal/`. App-specific `SKILL.md` files under `solstone/apps//talent//` are command-guidance fragments. They are not installed directly; `sol skills build` folds them into the generated router references. **Key Points:** -- Create a subdirectory in `solstone/talent/` with a `SKILL.md` file (YAML frontmatter + markdown body) -- The directory name must match the `name` field in the YAML frontmatter -- Skill names must be unique across system `solstone/talent/` and all `solstone/apps/*/talent/` directories -- `make skills` discovers all skills and symlinks them into `journal/.agents/skills/` and `journal/.claude/skills/` -- Skills are standalone — they don't interact with the talent agent/generator system -- The talent loader ignores subdirectories, so skills won't interfere with agent discovery - -**Directory structure:** +- Installed router skill directories live at `solstone/talent/sol/` and `solstone/talent/journal/`. +- App command fragments live at `solstone/apps//talent//SKILL.md`. +- The fragment directory name must match the `name` field in the YAML frontmatter. +- `make skills` runs `sol skills build`, then installs only the `sol` and `journal` router skill symlinks into `journal/.agents/skills/` and `journal/.claude/skills/`. +- `make ci` / `make install-checks` runs `sol skills build --check` and fails if generated references are stale. +- Router skills and app fragments are standalone from the talent agent/generator system; the talent loader ignores subdirectories. + +**Router skill directory structure:** ``` -talent/my-skill/ +talent/sol/ ├── SKILL.md # Required: YAML frontmatter + instructions -├── scripts/ # Optional: Executable code (Python, Bash, etc.) -├── references/ # Optional: Additional documentation loaded on demand -└── assets/ # Optional: Static resources (templates, data files) +└── references/ + └── commands.md # Generated by sol skills build ``` **SKILL.md format:** ```yaml --- -name: my-skill +name: sol description: Short description of what this skill does and when to use it. --- @@ -451,14 +450,13 @@ Step-by-step procedures, examples, and domain knowledge for the agent. - `metadata` — Arbitrary key-value string map - `allowed-tools` — Space-delimited list of pre-approved tools (experimental) -**App skills** work the same way — place a skill directory inside `solstone/apps/my_app/talent/`: +**App command fragments** use the same frontmatter shape and live under `solstone/apps/my_app/talent/`: ``` -apps/my_app/talent/my-skill/ -├── SKILL.md -└── references/ +apps/my_app/talent/my_app/ +└── SKILL.md ``` -**Running `make skills`:** Discovers all `SKILL.md` files under `solstone/talent/*/` and `solstone/apps/*/talent/*/`, then creates symlinks in `journal/.agents/skills/` and `journal/.claude/skills/` so that all supported coding agents see the same skills. Errors if two skills share the same directory name. +**Running `make skills`:** Regenerates `solstone/talent/sol/references/commands.md` and `solstone/talent/journal/references/commands.md`, then installs the `sol` and `journal` router skill symlinks into `journal/.agents/skills/` and `journal/.claude/skills/`. --- diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 34705631b..29fe1e4f9 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -132,18 +132,18 @@ def list_items( 2. **That's it for the CLI.** Auto-discovery picks it up on next run. -3. **Create the agent skill** (if agents should use these commands): +3. **Update the app command fragment** (if agents should use these commands): ```markdown # solstone/apps/myapp/talent/myapp/SKILL.md --- name: myapp description: > - What this skill does. When to trigger it. + What this command fragment covers. When to trigger it. TRIGGER: keyword1, keyword2, keyword3. --- -# MyApp CLI Skill +# MyApp CLI Fragment Common pattern: \`\`\`bash @@ -162,9 +162,9 @@ List items for a day. - `-f, --facet`: facet name (default: `SOL_FACET` env). ``` -4. **Run `sol skills install --project`** to create the symlink in `journal/.agents/skills/` (`make skills` wraps this). +4. **Run `sol skills build`** to regenerate the checked-in router references. `make skills` wraps this and then refreshes the installed `sol` + `journal` router skill symlinks. -5. **Update AGENTS.md** — add the skill to the Skills table. +5. **Run `sol skills build --check`** before committing, or rely on `make ci` / `make install-checks`. The check fails when generated router references are stale. ### Local-only think tools @@ -173,16 +173,16 @@ the journal host and depends heavily on `solstone/think/` internals. 1. **Create `solstone/think/tools/.py`** with `app = typer.Typer()` and a `main()` that calls `app()`. 2. **Register in `solstone/think/sol_cli.py`** with `surface="service"`. -3. **Optionally create a skill** in `solstone/talent//SKILL.md`. +3. **Optionally update a router skill reference** if the command needs agent-facing guidance. ### Files to maintain for a new call command | File | What to do | Required? | |------|-----------|-----------| | `solstone/apps//call.py` | Typer app with commands | Yes | -| `solstone/apps//talent//SKILL.md` | Skill doc for agents | If agents should use it | -| `journal/.agents/skills/` | Symlink (via `sol skills install --project`; `make skills` wrapper) | Auto-generated | -| `AGENTS.md` Skills table | Add trigger description | If skill exists | +| `solstone/apps//talent//SKILL.md` | App command guidance fragment used by `sol skills build` | If agents should use it | +| `solstone/talent/sol/references/commands.md` | Generated `sol call ` inventory | Auto-generated by `sol skills build` | +| `solstone/talent/journal/references/commands.md` | Generated journal-host command guidance | Auto-generated by `sol skills build` | | `tests/test__call.py` | CLI tests | Yes | ## Conventions @@ -371,7 +371,7 @@ solstone/ ├── solstone/apps/ │ ├── activities/ │ │ ├── call.py # sol call activities (auto-discovered) -│ │ └── talent/calendar/SKILL.md +│ │ └── talent/activities/SKILL.md # builder source for generated router refs │ ├── entities/call.py │ ├── speakers/call.py │ ├── support/call.py @@ -380,11 +380,13 @@ solstone/ │ ├── awareness/call.py │ └── ... (web-only apps without call.py) ├── talent/ -│ ├── journal/SKILL.md # Skills not tied to an app -│ ├── vit/SKILL.md +│ ├── sol/SKILL.md # installed sol router skill +│ ├── journal/SKILL.md # installed journal router skill +│ ├── sol/references/commands.md # generated app command inventory +│ ├── journal/references/commands.md # generated journal-host command guidance │ └── *.md # Agent prompt files -├── journal/.agents/skills/ # Symlinks (generated by sol skills install --project; make skills wrapper) -└── AGENTS.md # Sol identity + skill table +├── journal/.agents/skills/ # sol + journal router symlinks +└── AGENTS.md # Developer guide ``` ### The `solstone/apps/` dual role @@ -424,25 +426,26 @@ solstone/ | `awareness` | `solstone/apps/awareness/call.py` | status, imports, log, log-read | | `journal` | `solstone/think/tools/call.py` | search, events, facets, facet (show/create/update/rename/mute/unmute/delete/merge), news, agents, read, imports, import, retention purge, storage-summary | -`sol skills` manages coding-agent skill installation. +`sol skills` builds generated router references and manages coding-agent skill installation. ## Skill System -Skills are documented in `SKILL.md` files and symlinked into both `journal/.claude/skills/` and `journal/.agents/skills/` by `sol skills install --project`; `make skills` wraps this. +Project skill installation installs exactly two router skills into both `journal/.claude/skills/` and `journal/.agents/skills/`: `sol` and `journal`. `sol skills install --project` does not install per-app fragments or every `SKILL.md` as a top-level skill. **Skill locations:** -- App skills: `solstone/apps//talent//SKILL.md` -- Core skills: `solstone/talent//SKILL.md` +- Installed router skills: `solstone/talent/sol/SKILL.md`, `solstone/talent/journal/SKILL.md` +- App command fragments: `solstone/apps//talent//SKILL.md` +- Generated references: `solstone/talent/sol/references/commands.md`, `solstone/talent/journal/references/commands.md` -**Skill ≠ call command.** Not every skill has a corresponding `call.py`, and not every `call.py` has a skill: -- `health` and `vit` have skills but no `call.py` -- Some call apps provide the CLI while the skill provides agent behavioral context +App command fragments are builder source. `sol skills build` folds their guidance into deterministic, checked-in generated references. The generated references aggregate per-app command guidance, including health's `sol call health` commands and health's journal-host `journal health` / `journal talent` guidance. There is no in-repo `vit` skill. -Skills document the CLI commands but also add behavioral guidance beyond what `--help` shows (e.g., "check entity context before attaching a new relationship to avoid duplicates"). +Fragments document CLI commands and add behavioral guidance beyond what `--help` shows (e.g., "check entity context before attaching a new relationship to avoid duplicates"). Agents consume that guidance through the `sol` and `journal` router skill references. ### Keeping skills in sync -When you add or change a `sol call` command, update the corresponding SKILL.md. The skill doc is what agents actually read — they don't parse `--help` output. Include: +When you add or change a `sol call` command, update both the app's `call.py` verb and the corresponding app command fragment. The generated router references are what agents actually read — they don't parse `--help` output. Include: - Full command syntax with all flags - Behavior notes (edge cases, defaults, validation) - Examples showing common usage patterns + +Then run `sol skills build` or `make skills`. `sol skills build --check` is wired into `make ci` / `make install-checks` and fails when generated references are stale. diff --git a/docs/project-structure.md b/docs/project-structure.md index 7eda02034..ace00d8ac 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -9,7 +9,7 @@ solstone/ │ └── sol_cli.py # Unified CLI entry point (run: sol ) ├── convey/ # Web app frontend & backend ├── solstone/apps/ # Convey app extensions (see docs/APPS.md) -├── talent/ # Agent/generator configs + Agent Skills (talent/*/SKILL.md) +├── talent/ # Agent/generator configs + sol/journal router skills ├── tests/ # Pytest test suites + test fixtures under tests/fixtures/ ├── docs/ # All documentation (*.md files) ├── AGENTS.md # Development guidelines (this file) @@ -34,14 +34,14 @@ Each package has a README.md symlink pointing to its documentation in `docs/`. ## Agent & Skill Organization -`solstone/talent/*.md` stores agent personas and generator templates. Apps can add their own in `solstone/apps/*/talent/*.md`. Skills live at `solstone/talent/*/SKILL.md` and are symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `sol skills install --project`, wrapped by `make skills`. +`solstone/talent/*.md` stores agent personas and generator templates. The installed project skills are the two router skills at `solstone/talent/sol/` and `solstone/talent/journal/`. App command fragments under `solstone/apps/*/talent/*/SKILL.md` are builder source for generated router references, not top-level installed skills. ## File Locations - **Entry Points**: `solstone/think/sol_cli.py` `COMMANDS` dict - **Test Fixtures**: `tests/fixtures/journal/` - complete mock journal - **Live Logs**: `journal/health/.log` -- **Agent Personas**: `solstone/talent/*.md` (apps can add their own in `solstone/talent/`, see [docs/APPS.md](docs/APPS.md)) -- **Generator Templates**: `solstone/talent/*.md` (apps can add their own in `solstone/talent/`, see [docs/APPS.md](docs/APPS.md)) -- **Agent Skills**: `solstone/talent/*/SKILL.md` - symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `sol skills install --project`, wrapped by `make skills`; read https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices to create the best skills +- **Agent Personas**: `solstone/talent/*.md` (apps can add their own talent files under `solstone/apps/*/talent/`, see [docs/APPS.md](docs/APPS.md)) +- **Generator Templates**: `solstone/talent/*.md` (apps can add their own talent files under `solstone/apps/*/talent/`, see [docs/APPS.md](docs/APPS.md)) +- **Agent Skills**: `solstone/talent/{sol,journal}/SKILL.md` - the two router skills installed into `journal/.agents/skills/` and `journal/.claude/skills/`; app `SKILL.md` fragments feed generated references via `sol skills build` - **Scratch Space**: `scratch/` - git-ignored local workspace diff --git a/journal/AGENTS.md b/journal/AGENTS.md index c94107d73..6229896f4 100644 --- a/journal/AGENTS.md +++ b/journal/AGENTS.md @@ -2,4 +2,4 @@ > **First rule for AI agents in a journal**: before doing anything else, run `journal identity` to hydrate Sol's self, partner, agency, and awareness. The output of that command tells you who you are, who you're working with, and what's currently on your plate. -For the full journal layout and `sol call journal` CLI reference, read either `./.claude/skills/journal/` or `./.agents/skills/journal/`. Start with `SKILL.md`, then use `references/cli.md`, `references/config.md`, `references/facets.md`, `references/captures.md`, `references/logs.md`, and `references/storage.md`. +Installed router skills live at `./.claude/skills/{journal,sol}/` and `./.agents/skills/{journal,sol}/`. For the full journal layout and `sol call journal` CLI reference, start with the `journal` router's `SKILL.md`, then use `references/cli.md`, `references/config.md`, `references/facets.md`, `references/captures.md`, `references/logs.md`, `references/storage.md`, and the generated `references/commands.md`. For per-app `sol call ` guidance, use the `sol` router's generated `references/commands.md`. diff --git a/scripts/check_journal_io_access.py b/scripts/check_journal_io_access.py index 615ba5ba5..475cad26f 100644 --- a/scripts/check_journal_io_access.py +++ b/scripts/check_journal_io_access.py @@ -114,8 +114,6 @@ OWNER_FILES: frozenset[str] = frozenset( "solstone/think/journal_config.py", "solstone/think/schedule_config.py", "solstone/think/push/devices.py", - # Host-config mechanic only; writes ~/.config/solstone/.last-start-version, not journal domain state. - "solstone/think/start.py", # Link domain — device-pairing service state. "solstone/think/link/auth.py", "solstone/think/link/ca.py", diff --git a/scripts/check_journal_io_mechanic.py b/scripts/check_journal_io_mechanic.py index 125450e47..4d7d72881 100644 --- a/scripts/check_journal_io_mechanic.py +++ b/scripts/check_journal_io_mechanic.py @@ -114,11 +114,12 @@ EXCLUDED_FILES: frozenset[str] = frozenset( "solstone/observe/transcribe/_parakeet_coreml.py", "solstone/observe/transcribe/revai.py", "solstone/think/journal_export.py", - # UI/pipeline runtime state, not owner journal content. + # UI/pipeline runtime state and generated source artifacts, not owner journal content. "solstone/apps/home/routes.py", "solstone/apps/transcripts/routes.py", "solstone/think/data_state.py", "solstone/think/skills_cli.py", + "solstone/think/skills_build.py", } ) diff --git a/scripts/check_layer_hygiene.py b/scripts/check_layer_hygiene.py index b39762219..9fa2ee497 100644 --- a/scripts/check_layer_hygiene.py +++ b/scripts/check_layer_hygiene.py @@ -32,6 +32,8 @@ import subprocess import sys from pathlib import Path +from solstone.think.command_polarity import is_read_verb + ROOT = Path(__file__).resolve().parent.parent # Module families scrutinized as "infrastructure" per L1/L6/L7. @@ -70,37 +72,6 @@ TARGET_PATH_PATTERNS: tuple[re.Pattern[str], ...] = ( ), ) -# Read verbs per docs/coding-standards.md § L3. Match against any -# underscore-split segment of the function name. -READ_VERBS: frozenset[str] = frozenset( - { - "load", - "get", - "read", - "scan", - "list", - "show", - "find", - "match", - "resolve", - "query", - "lookup", - "status", - "check", - "validate", - "discover", - "format", - "render", - "extract", - "parse", - "view", - "inspect", - "info", - "describe", - "search", - } -) - # Temporary, file-scoped exceptions for known layer-hygiene violations. # Keep this empty by default; add entries only with a tracking identifier # and remove them in the same bundle that fixes the violation. @@ -153,8 +124,7 @@ def scan_lines(lines: list[str]) -> list[tuple[int, str]]: def has_read_verb(name: str) -> bool: - base = name.lstrip("_") - return any(part in READ_VERBS for part in base.split("_") if part) + return is_read_verb(name) def check_call_py(rel: Path, source: str) -> list[tuple[int, str, str]]: diff --git a/solstone/talent/journal/SKILL.md b/solstone/talent/journal/SKILL.md index 83c51a286..cbd5f158e 100644 --- a/solstone/talent/journal/SKILL.md +++ b/solstone/talent/journal/SKILL.md @@ -63,3 +63,4 @@ For the full table, see [storage](references/storage.md). - [Captures and Extracts](references/captures.md) — layers, imports, segment layout - [Logs](references/logs.md) — action logs, token usage, talent logs, health - [Storage](references/storage.md) — top-level layout, app storage, search index +- [Commands](references/commands.md) — journal-host command guidance contributed by apps (generated) diff --git a/solstone/talent/journal/references/commands.md b/solstone/talent/journal/references/commands.md new file mode 100644 index 000000000..d4c16fd3a --- /dev/null +++ b/solstone/talent/journal/references/commands.md @@ -0,0 +1,11 @@ + + +# Journal Router Commands + +Generated app-contributed command guidance for the `journal` router. + +## health — `journal health`, `journal talent` + +Triggers: `health`, `status`, `is it running`, `service down`, `errors`, `agent runs`, `logs`, `pipeline`, `journal health`, `journal talent logs` + +Guidance: `solstone/apps/health/talent/health/SKILL.md` diff --git a/solstone/talent/solstone/SKILL.md b/solstone/talent/sol/SKILL.md similarity index 53% rename from solstone/talent/solstone/SKILL.md rename to solstone/talent/sol/SKILL.md index 43eca713f..4233d1810 100644 --- a/solstone/talent/solstone/SKILL.md +++ b/solstone/talent/sol/SKILL.md @@ -1,18 +1,22 @@ --- -name: solstone +name: sol version: 1.0.0 description: > - Read-only query of the solstone journal from any project. Look up people - and relationships, today's events; read transcripts. TRIGGER: - solstone, my journal, search my memory, what happened, who is, meeting - with, co-brain, recall, sol call journal/entities/transcripts. + Read-only query of the solstone journal from any project, plus routing + guidance for app-specific `sol call ` commands. Look up people and + relationships, today's events; read transcripts. TRIGGER: solstone, my + journal, search my memory, what happened, who is, meeting with, co-brain, + recall, sol call routing, sol call journal/entities/transcripts. --- -# solstone — journal query interface +# sol — journal query router -Read-only query interface to your solstone journal. Invoke via Bash: `sol call [flags]`. +Read-only query interface to your solstone journal. Invoke via Bash: +`sol call [flags]`. -Use this skill to search memories, look up people, check today's events, and read transcripts — all from any project context. +Use this skill to search memories, look up people, check today's events, read +transcripts, and choose the right `sol call` app command from outside the +solstone project context. ## Prerequisites @@ -22,7 +26,8 @@ The `sol` CLI must be on PATH. Quick check: sol help ``` -If this fails, solstone is not installed. Install it from the solstone project: `journal setup`. +If this fails, solstone is not installed. Install it from the solstone project: +`journal setup`. ## Capabilities @@ -37,29 +42,24 @@ sol call journal search "" --facet work sol call journal search "" --day-from 20260320 --day-to 20260327 ``` -Dates use `YYYYMMDD` format. Omit `--day` to search all days. Omit `--facet` to search all facets. +Dates use `YYYYMMDD` format. Omit `--day` to search all days. Omit `--facet` to +search all facets. ### who — entities Look up what the journal knows about a person, company, or project. ```bash -# Search for entities by text, type, or activity sol call entities search --query "" - -# See observations recorded for an entity (requires facet) sol call entities observations "" --facet "" ``` ### today — what's happening now -Combine these commands to get a full picture of the current day: +Combine these commands to get a picture of the current day: ```bash -# Calendar events for today sol call activities list --source anticipated - -# Latest facet news sol call journal news "" ``` @@ -68,36 +68,36 @@ sol call journal news "" Read what was said during meetings or any recorded time. ```bash -# List transcript coverage ranges for a day sol call transcripts scan sol call transcripts scan 20260327 - -# Read transcript content for a specific time range sol call transcripts read -sol call transcripts read 20260327 sol call transcripts read 20260327 --start 140000 --length 120 - -# Transcript stats for a month sol call transcripts stats ``` -`scan` first to see what's available, then `read` with `--start` (HHMMSS, 24h format) and `--length` (minutes) to narrow down. +`scan` first to see what's available, then `read` with `--start` (HHMMSS, +24-hour format) and `--length` (minutes) to narrow down. ### status — system health Check if solstone is running and how much data exists. ```bash -# Journal storage summary (days, facets, size) sol call journal storage-summary - -# Journal-host diagnostics (read-only) sol call support diagnose ``` +## Per-app command map + +See [Commands](references/commands.md) for the generated inventory of every +`sol call ` command contributed by app skill fragments, including triggers +and read/write/other polarity. Regenerate it with `sol skills build` or +`make skills`; do not inline those command tables here. + ## Paths -`sol root` prints the solstone repo root — useful for scripting: `cd $(sol root)`, `SOL=$(sol root)`. +`sol root` prints the solstone repo root, useful for scripting: +`cd $(sol root)`. ## Environment @@ -107,24 +107,26 @@ The `sol` CLI uses three environment variables that default sensibly: - `SOL_FACET` — defaults to all facets - `SOL_SEGMENT` — defaults to no segment -External callers should never need to set these. The commands above use explicit flags (`--day`, `--facet`) when narrowing scope is needed. +External callers should not need to set these. Use explicit flags (`--day`, +`--facet`) when narrowing scope is needed. ## Composing queries -For richer answers, combine multiple commands: +**"Brief me on today"** — events plus active relationships: -**"Brief me on today"** — events + active relationships: ```bash sol call activities list --source anticipated sol call entities search --limit 5 ``` **"Prep me for a meeting with X"** — recent transcript mentions: + ```bash sol call journal search "" --day-from 20260320 ``` -**"What did I miss yesterday?"** — yesterday's events + transcripts + news: +**"What did I miss yesterday?"** — yesterday's events, transcripts, and news: + ```bash sol call journal search "" -d 20260326 -a meetings sol call transcripts scan 20260326 @@ -133,11 +135,14 @@ sol call journal news "" --day 20260326 ## Output format -Most commands output plain text by default. Many support `--json` for structured output. Prefer plain text for human-readable answers; use `--json` when you need to process the data further. +Most commands output plain text by default. Many support `--json` for structured +output. Prefer plain text for human-readable answers; use `--json` when you need +to process the data further. ## What you cannot do -This is a **read-only** interface. The journal is the person's private space. You cannot: +This is a read-only interface. The journal is the person's private space. You +cannot: - Create, delete, or modify facets - Attach or modify entities @@ -145,14 +150,20 @@ This is a **read-only** interface. The journal is the person's private space. Yo - Run pipeline operations (think, indexer, transcribe) - Access internal agent state or orchestration -If a task requires writing to the journal, it must be done from within the solstone project context using sol's internal skills. +If a task requires writing to the journal, it must be done from within the +solstone project context using sol's internal skills. ## Error handling If `sol` is not found on PATH or returns an error: -- **"command not found: sol"** — solstone is not installed. The user needs to run `journal setup` in their solstone project. -- **"journal not found"** or empty output — the journal directory doesn't exist or has no data yet. solstone may be installed but not yet initialized. -- **Connection errors from `sol call support`** — every support command needs the local solstone service reachable; if it isn't, the command reports that and `diagnose` falls back to showing local build identity. Portal-backed commands (`search`, `article`) can additionally fail when the journal host is offline. +- `"command not found: sol"` — solstone is not installed. The user needs to run + `journal setup` in their solstone project. +- `"journal not found"` or empty output — the journal directory doesn't exist or + has no data yet. solstone may be installed but not initialized. +- Connection errors from `sol call support` — every support command needs the + local solstone service reachable. Portal-backed commands (`search`, `article`) + can additionally fail when the journal host is offline. -Do not retry failed commands. Report the error clearly so the user can investigate. +Do not retry failed commands. Report the error clearly so the user can +investigate. diff --git a/solstone/talent/sol/references/commands.md b/solstone/talent/sol/references/commands.md new file mode 100644 index 000000000..c8bcf26fd --- /dev/null +++ b/solstone/talent/sol/references/commands.md @@ -0,0 +1,71 @@ + + +# Sol Router Commands + +Generated inventory of app command guidance for the `sol` router. + +## activities — `sol call activities` + +Triggers: `activity`, `activities`, `work session`, `completed span`, `mute/unmute`, `activity record`, `meeting attendees` + +Read: `get`, `list` + +Write: `create`, `update` + +Other: `mute`, `unmute` + +Guidance: `solstone/apps/activities/talent/activities/SKILL.md` + +## entities — `sol call entities` + +Triggers: `entity`, `person`, `company`, `relationship`, `who is`, `contact` + +Read: `list`, `search` + +Write: `accept-merge-candidate`, `attach`, `consolidate`, `dismiss-merge-candidate`, `merge`, `merge-candidates`, `move`, `record-merge-candidate`, `update` + +Other: `aka`, `detect`, `observations`, `observe` + +Guidance: `solstone/apps/entities/talent/entities/SKILL.md` + +## health — `sol call health` + +Triggers: `health`, `status`, `is it running`, `service down`, `errors`, `agent runs`, `logs`, `pipeline`, `journal health`, `journal talent logs` + +Other: `for-range`, `full`, `pipeline`, `summary` + +Guidance: `solstone/apps/health/talent/health/SKILL.md` + +## speakers — `sol call speakers` + +Triggers: `speaker`, `voice`, `who was talking`, `identify speaker`, `voiceprint` + +Read: `discover`, `resolve-names`, `status` + +Write: `backfill`, `backfill-last-seen`, `bootstrap`, `link-import`, `merge-names`, `seed-from-imports` + +Other: `attribute-segment`, `confirm-owner`, `detect`, `identify`, `owner-ready`, `reject-owner`, `suggest`, `wipe` + +Guidance: `solstone/apps/speakers/talent/speakers/SKILL.md` + +## support — `sol call support` + +Triggers: `file bug`, `request feature`, `submit feedback`, `search KB`, `announcements`, `tickets` + +Read: `list`, `search`, `show` + +Write: `attach`, `create` + +Other: `announcements`, `article`, `diagnose`, `feedback`, `register`, `reply` + +Guidance: `solstone/apps/support/talent/support/SKILL.md` + +## transcripts — `sol call transcripts` + +Triggers: `transcript`, `recording`, `audio`, `what was said`, `conversation`, `segment`, `screen capture` + +Read: `read`, `scan` + +Other: `segments`, `stats` + +Guidance: `solstone/apps/transcripts/talent/transcripts/SKILL.md` diff --git a/solstone/talent/vit/SKILL.md b/solstone/talent/vit/SKILL.md deleted file mode 100644 index 47011a452..000000000 --- a/solstone/talent/vit/SKILL.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: vit -description: >- - Ship solstone features as vit caps to the social capability network - after a feature lands through hopper. Scoped to the publish/ship - workflow that runs at the end of the VPE playbook; discovery, vetting, - and consumption commands live in the user-wide `using-vit` skill. - TRIGGER: ship a cap, publish solstone cap, vit ship, after hopper ship, - final lode ship, capworthy feature, three-word ref, vit beacon, - vit:github.com/solpbc/solstone-journal. ---- - -# Vit Ship Skill - -Publish solstone capabilities as vit caps. Invoke via Bash: `vit ship ...` (other vit commands are covered by the `using-vit` skill). - -## overview - -vit is a CLI for publishing software capabilities (caps) to a decentralized social network built on ATProto. solstone participates in the vit network — when a capworthy feature ships, the VPE session publishes it as a cap so other projects and agents can discover, vet, and remix it. - -**beacon:** `vit:github.com/solpbc/solstone-journal` - -**Scope**: this skill is intentionally narrow — VPE-session ship workflow only. For `vit skim`, `vit follow`, `vit learn`, `vit remix`, `vit vet`, and general vit usage, the `using-vit` skill has full coverage. Don't duplicate that reference here. - -## when to ship a cap - -ship a cap after the **final lode** of a feature ships through hopper and progress is updated (VPE playbook step 5). not every lode is a cap. a cap describes a self-contained capability another project could learn from or adopt. - -**ship when:** -- the feature introduces a novel pattern or approach worth sharing -- the feature is self-contained — someone could read the cap and understand the full approach -- the feature would be useful to other agentic projects, journaling tools, or AI-native software - -**don't ship when:** -- internal refactoring or code cleanup -- bug fixes (unless the fix embodies a pattern worth documenting) -- test-only or docs-only changes -- individual lodes within a multi-lode feature (ship one cap for the whole feature at the end) - -## how to ship - -```bash -vit ship --title "Feature Name" \ - --description "One sentence explaining the value" \ - --ref "three-word-ref" \ - --kind feat <<'EOF' -What this feature does, how it works, and the key architectural decisions. -Written for another developer or agent who might adopt the approach in -their own codebase. -EOF -``` - -### field guide - -- `--title` — concise noun phrase, 2-5 words (e.g., "Entity Intelligence Signals Index") -- `--description` — one sentence explaining the value to someone who hasn't seen the code -- `--ref` — three lowercase words separated by dashes, memorable slug for discovery (e.g., "entity-signal-indexing") -- `--kind` — category: `feat`, `fix`, `test`, `docs`, `refactor`, `chore`, `perf`, `style` -- `--recap ` — only if this cap derives from another cap (e.g., after `vit remix`) -- **body (stdin)** — a short paragraph explaining the approach. not a commit message — write it for someone who wants to understand and potentially adopt the pattern - -### ref naming conventions - -the three-word ref should be descriptive and memorable: -- good: `entity-signal-indexing`, `speaker-voice-attribution`, `routine-progressive-discovery` -- bad: `update-fix-three`, `new-feature-impl`, `march-twentyseven-ship` - -## pre-ship diagnostics - -### `vit init` -already done — beacon is set. run `vit init` to check current beacon status, or `vit init --beacon ` to change it. - -### `vit doctor` -read-only diagnostic. run to verify setup and beacon status before shipping when something looks off. - -For `vit skim`, `vit follow`, `vit learn`, `vit remix`, and other discovery/consumption commands, see the `using-vit` skill. - -## troubleshooting - -| error | fix | -|-------|-----| -| `no DID configured` | tell the user to run `vit login ` in their terminal | -| `no beacon set` | run `vit init --beacon .` | -| `session expired` | tell the user to run `vit login ` | -| invalid ref format | ref must be exactly three lowercase words separated by dashes | - -## human-only commands - -these require browser interaction — tell the user to run them in their terminal: -- `vit setup` — check prerequisites -- `vit login ` — authenticate via browser OAuth -- `vit vet ` — review a cap before trusting it diff --git a/solstone/think/command_polarity.py b/solstone/think/command_polarity.py new file mode 100644 index 000000000..36abd60f8 --- /dev/null +++ b/solstone/think/command_polarity.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Shared command verb polarity helpers.""" + +from __future__ import annotations + +import re + +READ_VERBS: frozenset[str] = frozenset( + { + "load", + "get", + "read", + "scan", + "list", + "show", + "find", + "match", + "resolve", + "query", + "lookup", + "status", + "check", + "validate", + "discover", + "format", + "render", + "extract", + "parse", + "view", + "inspect", + "info", + "describe", + "search", + } +) + +WRITE_VERBS: frozenset[str] = frozenset( + { + "save", + "create", + "add", + "insert", + "append", + "attach", + "delete", + "remove", + "update", + "rename", + "move", + "promote", + "merge", + "seed", + "consolidate", + "bootstrap", + "backfill", + "dispatch", + "record", + "ingest", + "import", + "rebuild", + } +) + + +def _verb_segments(verb: str) -> list[str]: + base = verb.lstrip("_") + return [part for part in re.split(r"[-_]+", base) if part] + + +def is_read_verb(verb: str) -> bool: + """Return True when any hyphen/underscore-split segment is a read verb.""" + return any(part in READ_VERBS for part in _verb_segments(verb)) + + +def classify_verb(verb: str) -> str: + """Classify a CLI verb as read, write, or other by naming convention.""" + segments = _verb_segments(verb) + if any(part in READ_VERBS for part in segments): + return "read" + if any(part in WRITE_VERBS for part in segments): + return "write" + return "other" diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index fcb948ee9..c4293722a 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -44,7 +44,7 @@ from pathlib import Path from typing import IO, Callable, Sequence from solstone.think import features as _features -from solstone.think import parakeet_readiness +from solstone.think import parakeet_readiness, skills_cli from solstone.think.health_cli import fetch_supervisor_status from solstone.think.probe import ( CONFIG_DIR_READABLE_CHECK, @@ -91,6 +91,7 @@ Runner = Callable[[Args], CheckResult] SOL_IMPORTABLE_CHECK = Check("sol_importable", "blocker", ("linux", "darwin")) STALE_ALIAS_CHECK = Check("stale_alias_symlink", "blocker", ("linux", "darwin")) +SKILL_STATE_CHECK = Check("skill_state", "advisory", ("linux", "darwin")) JOURNAL_DIR_WRITABLE_CHECK = Check( "journal_dir_writable", "blocker", ("linux", "darwin") ) @@ -437,6 +438,80 @@ def stale_alias_symlink_check(args: Args, binary: str) -> CheckResult: ) +def _skill_state_problem_detail( + skills_dir: Path, expected_sources: dict[str, Path] +) -> list[str]: + problems: list[str] = [] + expected_names = set(expected_sources) + + for name, source in sorted(expected_sources.items()): + link = skills_dir / name + if not link.is_symlink(): + problems.append(f"missing router {name} at {link}") + continue + target_text = os.readlink(link) + target_path = (skills_dir / target_text).resolve(strict=False) + if target_path != source.resolve(strict=False): + problems.append(f"foreign router {name} at {link} -> {target_text}") + + for link in sorted(skills_dir.iterdir()): + if link.name in expected_names or not link.is_symlink(): + continue + problems.append(f"stale skill link {link.name} at {link}") + + return problems + + +def skill_state_check(args: Args) -> CheckResult: + del args + check = SKILL_STATE_CHECK + if is_packaged_install(): + return make_result( + check, + "skip", + "project skill links are a source-checkout concept", + ) + + journal_text, _source = get_journal_info() + journal_path = Path(journal_text) + if not journal_path.exists(): + return make_result(check, "skip", "no local journal") + + try: + sources = skills_cli.discover_project_sources(ROOT) + except Exception as exc: + return make_result(check, "skip", f"project skill sources unavailable: {exc}") + + expected_sources = {source.name: source for source in sources} + skill_dirs = [ + journal_path / ".claude" / "skills", + journal_path / ".agents" / "skills", + ] + existing_dirs = [path for path in skill_dirs if path.is_dir()] + if not existing_dirs: + return make_result(check, "skip", "no installed project skill dirs") + + problems: list[str] = [] + for skills_dir in existing_dirs: + problems.extend(_skill_state_problem_detail(skills_dir, expected_sources)) + + if not problems: + names = ", ".join( + name for name in skills_cli.ROUTER_SKILL_NAMES if name in expected_sources + ) + return make_result( + check, + "ok", + f"router skills {names} are installed and current", + ) + + fix = ( + f"repair {', '.join(str(path) for path in existing_dirs)}: run `journal setup` " + f"or `sol skills install --project {journal_path} --agent all`" + ) + return make_result(check, "warn", "; ".join(problems), fix) + + def launchd_stale_plist_check(args: Args) -> CheckResult: del args check = LAUNCHD_STALE_PLIST_CHECK @@ -608,6 +683,7 @@ UNIVERSAL_CHECKS: list[tuple[Check, Runner]] = [ (SOL_IMPORTABLE_CHECK, sol_importable_check), (LOCAL_BIN_SOL_REACHABLE_CHECK, local_bin_sol_reachable_check), (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="sol")), + (SKILL_STATE_CHECK, skill_state_check), ] JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ @@ -621,6 +697,7 @@ JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="journal")), (LAUNCHD_STALE_PLIST_CHECK, launchd_stale_plist_check), (DEFAULT_STT_READY_CHECK, default_stt_ready_check), + (SKILL_STATE_CHECK, skill_state_check), *FEATURE_CHECKS.values(), ] diff --git a/solstone/think/setup.py b/solstone/think/setup.py index 403ee664d..76c19cb07 100644 --- a/solstone/think/setup.py +++ b/solstone/think/setup.py @@ -1162,9 +1162,9 @@ def step_install_models(ctx: SetupContext, step_index: int) -> StepResult: def skills_user_paths() -> list[Path]: return [ - Path.home() / ".claude" / "skills" / "solstone" / "SKILL.md", - Path.home() / ".codex" / "skills" / "solstone" / "SKILL.md", - Path.home() / ".gemini" / "skills" / "solstone" / "SKILL.md", + Path.home() / ".claude" / "skills" / "sol" / "SKILL.md", + Path.home() / ".codex" / "skills" / "sol" / "SKILL.md", + Path.home() / ".gemini" / "skills" / "sol" / "SKILL.md", ] @@ -1636,7 +1636,7 @@ def print_plan(ctx: SetupContext, *, dry_run: bool) -> None: narrate(ctx, f" would run: {format_command(install_models_command(ctx))}") narrate( ctx, - f"[step 4/7] {_STEP_NAME[step_skills_user]} - installs solstone bundle for claude / codex / gemini", + f"[step 4/7] {_STEP_NAME[step_skills_user]} - installs the sol skill for claude / codex / gemini", ) if ctx.skip_skills: narrate(ctx, " skipped: --skip-skills") diff --git a/solstone/think/skills_build.py b/solstone/think/skills_build.py new file mode 100644 index 000000000..5ef12c06b --- /dev/null +++ b/solstone/think/skills_build.py @@ -0,0 +1,272 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Build generated command references for router skills.""" + +from __future__ import annotations + +import importlib +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import frontmatter +import typer +from typer.main import get_command + +from solstone.think.command_polarity import classify_verb + +ROOT = Path(__file__).resolve().parents[2] + +FRAGMENT_SOURCES: dict[str, Path] = { + "activities": Path("solstone/apps/activities/talent/activities/SKILL.md"), + "entities": Path("solstone/apps/entities/talent/entities/SKILL.md"), + "health": Path("solstone/apps/health/talent/health/SKILL.md"), + "speakers": Path("solstone/apps/speakers/talent/speakers/SKILL.md"), + "support": Path("solstone/apps/support/talent/support/SKILL.md"), + "transcripts": Path("solstone/apps/transcripts/talent/transcripts/SKILL.md"), +} +HEALTH_JOURNAL_NAMESPACES = ("journal health", "journal talent") +JOURNAL_NAMESPACE_CONTRIBUTIONS = {"health": HEALTH_JOURNAL_NAMESPACES} + +SOL_COMMANDS_PATH = Path("solstone/talent/sol/references/commands.md") +JOURNAL_COMMANDS_PATH = Path("solstone/talent/journal/references/commands.md") + +_BANNER = ( + "" +) + + +@dataclass(frozen=True) +class Fragment: + app: str + path: Path + repo_path: str + description: str + triggers: tuple[str, ...] + + +def _absolute(path: Path) -> Path: + return path if path.is_absolute() else ROOT / path + + +def _repo_relative(path: Path) -> str: + try: + return path.relative_to(ROOT).as_posix() + except ValueError: + return path.as_posix() + + +def _parse_triggers(description: str) -> tuple[str, ...]: + marker = "TRIGGER:" + if marker not in description: + return () + triggers: list[str] = [] + for raw_token in description.split(marker, 1)[1].split(","): + token = raw_token.strip() + if token.lower().startswith("sol call"): + continue + token = token.removesuffix(".").strip() + if token: + triggers.append(token) + return tuple(triggers) + + +def _load_fragment(rel_path: Path) -> Fragment: + path = _absolute(rel_path) + try: + post = frontmatter.load(path) + except Exception as exc: + raise ValueError(f"{path}: malformed frontmatter: {exc}") from exc + + name = post.metadata.get("name") + if not name: + raise ValueError(f"{path}: missing frontmatter name") + if name != path.parent.name: + raise ValueError( + f"{path}: frontmatter name {name!r} does not match directory {path.parent.name!r}" + ) + description = post.metadata.get("description") + if not description: + raise ValueError(f"{path}: missing frontmatter description") + if not isinstance(description, str): + raise ValueError(f"{path}: frontmatter description must be a string") + + return Fragment( + app=name, + path=path, + repo_path=_repo_relative(path), + description=description, + triggers=_parse_triggers(description), + ) + + +def _load_fragments() -> dict[str, Fragment]: + fragments: dict[str, Fragment] = {} + seen_names: dict[str, Path] = {} + for app_key, rel_path in FRAGMENT_SOURCES.items(): + fragment = _load_fragment(rel_path) + previous = seen_names.get(fragment.app) + if previous is not None: + raise ValueError( + f"{fragment.path}: duplicate app key {fragment.app!r}: " + f"{previous} and {fragment.path}" + ) + if fragment.app != app_key: + raise ValueError( + f"{fragment.path}: fragment key {app_key!r} does not match name " + f"{fragment.app!r}" + ) + seen_names[fragment.app] = fragment.path + fragments[fragment.app] = fragment + return fragments + + +def _resolve_typer_app(app_name: str, source: Path) -> typer.Typer: + module_name = ( + "solstone.think.tools.health" + if app_name == "health" + else f"solstone.apps.{app_name}.call" + ) + try: + module = importlib.import_module(module_name) + except Exception as exc: + raise ValueError(f"{source}: cannot import {module_name}: {exc}") from exc + + app = getattr(module, "app", None) + if not isinstance(app, typer.Typer): + raise ValueError(f"{source}: {module_name} does not export a Typer app") + return app + + +def _command_names(app_name: str, source: Path) -> tuple[str, ...]: + app = _resolve_typer_app(app_name, source) + try: + command = get_command(app) + names = tuple(sorted(command.commands.keys())) + except Exception as exc: + raise ValueError(f"{source}: cannot introspect Typer commands: {exc}") from exc + if not names: + raise ValueError(f"{source}: Typer app has no commands") + return names + + +def _format_list(values: tuple[str, ...]) -> str: + return ", ".join(f"`{value}`" for value in values) + + +def _polarity_groups(verbs: tuple[str, ...]) -> dict[str, tuple[str, ...]]: + grouped = {"read": [], "write": [], "other": []} + for verb in verbs: + grouped[classify_verb(verb)].append(verb) + return {group: tuple(sorted(values)) for group, values in grouped.items() if values} + + +def _render_sol(fragments: dict[str, Fragment]) -> str: + lines = [ + _BANNER, + "", + "# Sol Router Commands", + "", + "Generated inventory of app command guidance for the `sol` router.", + "", + ] + for app_name in sorted(fragments): + fragment = fragments[app_name] + verbs = _command_names(app_name, fragment.path) + groups = _polarity_groups(verbs) + lines.extend( + [ + f"## {app_name} — `sol call {app_name}`", + "", + ] + ) + if fragment.triggers: + lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""]) + for label, key in (("Read", "read"), ("Write", "write"), ("Other", "other")): + values = groups.get(key) + if values: + lines.extend([f"{label}: {_format_list(values)}", ""]) + lines.extend([f"Guidance: `{fragment.repo_path}`", ""]) + return "\n".join(lines).rstrip() + "\n" + + +def _render_journal(fragments: dict[str, Fragment]) -> str: + lines = [ + _BANNER, + "", + "# Journal Router Commands", + "", + "Generated app-contributed command guidance for the `journal` router.", + "", + ] + for app_name in sorted(JOURNAL_NAMESPACE_CONTRIBUTIONS): + fragment = fragments.get(app_name) + if fragment is None: + raise ValueError( + f"{app_name}: journal namespace contribution has no fragment" + ) + namespaces = JOURNAL_NAMESPACE_CONTRIBUTIONS[app_name] + formatted_namespaces = ", ".join(f"`{namespace}`" for namespace in namespaces) + lines.extend( + [ + f"## {app_name} — {formatted_namespaces}", + "", + ] + ) + if fragment.triggers: + lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""]) + lines.extend([f"Guidance: `{fragment.repo_path}`", ""]) + return "\n".join(lines).rstrip() + "\n" + + +def render() -> dict[str, str]: + """Render both generated reference files fully in memory.""" + fragments = _load_fragments() + return { + str(_absolute(SOL_COMMANDS_PATH)): _render_sol(fragments), + str(_absolute(JOURNAL_COMMANDS_PATH)): _render_journal(fragments), + } + + +def _atomic_write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as out_file: + out_file.write(content) + os.replace(temp_path, path) + except Exception: + try: + os.unlink(temp_path) + except Exception: + pass + raise + + +def build() -> list[Path]: + """Write generated reference files atomically and return written paths.""" + outputs = render() + written: list[Path] = [] + for raw_path, content in outputs.items(): + path = Path(raw_path) + _atomic_write_text(path, content) + written.append(path) + return written + + +def check() -> list[Path]: + """Return generated reference paths whose on-disk content is stale.""" + stale: list[Path] = [] + for raw_path, content in render().items(): + path = Path(raw_path) + try: + current = path.read_text(encoding="utf-8") + except FileNotFoundError: + stale.append(path) + continue + if current != content: + stale.append(path) + return stale diff --git a/solstone/think/skills_cli.py b/solstone/think/skills_cli.py index 6f3d6f488..689bae43b 100644 --- a/solstone/think/skills_cli.py +++ b/solstone/think/skills_cli.py @@ -1,17 +1,17 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""sol skills — install, uninstall, and inspect coding-agent skills. +"""sol skills — build, install, uninstall, and inspect coding-agent skills. Two install modes: - User mode (default): copies the bundled umbrella skill - solstone/talent/solstone/ into per-agent user config directories + solstone/talent/sol/ into per-agent user config directories (~/.claude/skills/, ~/.codex/skills/, ~/.gemini/skills/). -- Project mode (--project [DIR]): symlinks every talent/ and apps/*/talent/ - SKILL.md source into /.claude/skills/ and /.agents/skills/. +- Project mode (--project [DIR]): symlinks the two router skills (sol, journal) + into /.claude/skills/ and /.agents/skills/. -Subcommands: install, uninstall, list. +Subcommands: build, install, uninstall, list. """ from __future__ import annotations @@ -32,11 +32,12 @@ ALL_AGENTS = "all" PROJECT_MULTI_AGENT = "agents" PROJECT_CLAUDE_SKILLS_REL = ".claude/skills" PROJECT_AGENTS_SKILLS_REL = ".agents/skills" +ROUTER_SKILL_NAMES = ("sol", "journal") GLOBAL_SKIP_MESSAGE = ( "no AI coding agent config directories found — skipping skill registration" ) -SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/solstone/ in per-agent user config dirs. -Project mode: symlinks/removes every talent/ and apps/*/talent/ skill under DIR. +SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/sol/ in per-agent user config dirs. +Project mode: symlinks/removes the two router skills (sol, journal) under DIR. User-mode install creates missing agent config dirs and atomically replaces a changed skill target.""" @@ -116,23 +117,26 @@ AGENTS: dict[str, AgentSpec] = { def resolve_user_skill() -> Path: """Return the bundled umbrella user skill source directory.""" - skill_dir = Path(str(resources.files("solstone.talent") / "solstone")) + skill_dir = Path(str(resources.files("solstone.talent") / "sol")) skill_file = skill_dir / "SKILL.md" if not skill_file.is_file(): raise FileNotFoundError( "expected bundled umbrella skill at " - f"solstone/talent/solstone/SKILL.md ({skill_file})" + f"solstone/talent/sol/SKILL.md ({skill_file})" ) return skill_dir def discover_project_sources(repo_root: Path) -> list[Path]: """Return project skill source directories, rejecting duplicate names.""" - package_root = repo_root / "solstone" - sources = sorted( - [path.parent for path in (package_root / "talent").glob("*/SKILL.md")] - + [path.parent for path in (package_root / "apps").glob("*/talent/*/SKILL.md")] - ) + sources: list[Path] = [] + for name in ROUTER_SKILL_NAMES: + source = repo_root / "solstone" / "talent" / name + skill_file = source / "SKILL.md" + if not skill_file.is_file(): + raise FileNotFoundError(f"expected project skill at {skill_file}") + sources.append(source) + sources = sorted(sources) seen: dict[str, Path] = {} for source in sources: previous = seen.get(source.name) @@ -366,7 +370,18 @@ def _remove_stale_project_links( if not link_parent.is_dir(): return for link in sorted(link_parent.iterdir()): - if not link.is_symlink() or link.name in source_names: + if link.name in source_names: + continue + if not link.is_symlink(): + rows.append( + ActionRow( + agent, + link.name, + "warning", + link, + reason="user content at stale target preserved", + ) + ) continue try: link.unlink() @@ -524,9 +539,9 @@ def _build_parser() -> argparse.ArgumentParser: prog="sol skills", description=( "Install, uninstall, and inspect coding-agent skills. " - "User mode copies the bundled umbrella skill solstone/talent/solstone/ " - "into per-agent user config dirs. Project mode symlinks every talent/ " - "and apps/*/talent/ SKILL.md source into the selected project directory. " + "User mode copies the bundled umbrella skill solstone/talent/sol/ " + "into per-agent user config dirs. Project mode symlinks the two " + "router skills (sol, journal) into the selected project directory. " "User-mode install creates missing agent config dirs and atomically " "replaces a changed skill target." ), @@ -554,6 +569,15 @@ def _build_parser() -> argparse.ArgumentParser: list_parser = subparsers.add_parser("list", help="list skill install status") _add_project_option(list_parser) + build_parser = subparsers.add_parser( + "build", help="build generated router skill references" + ) + build_parser.add_argument( + "--check", + action="store_true", + help="verify generated router skill references are current without writing", + ) + return parser @@ -579,9 +603,28 @@ def main() -> int: parser = _build_parser() args = parser.parse_args() repo_root = Path(get_project_root()) - target = _resolve_project_target(args.project) + target = _resolve_project_target(getattr(args, "project", None)) try: + if args.cmd == "build": + from solstone.think import skills_build + + if args.check: + stale = skills_build.check() + for path in stale: + print( + f"stale generated reference {path} (run `sol skills build`)", + file=sys.stderr, + ) + if stale: + return 1 + print("generated skill references are current") + return 0 + + for path in skills_build.build(): + print(f"generated {path}") + return 0 + if args.cmd == "install": if target is None: skill_dir = resolve_user_skill() diff --git a/solstone/think/start.py b/solstone/think/start.py index 64a4deffc..22fd58273 100644 --- a/solstone/think/start.py +++ b/solstone/think/start.py @@ -5,68 +5,8 @@ from __future__ import annotations -import logging -import sys -from pathlib import Path - -import solstone -from solstone.think.app_supervised import is_app_supervised -from solstone.think.install_guard import alias_paths, install_wrappers -from solstone.think.journal_io import atomic_replace -from solstone.think.service import reconcile_installed_unit -from solstone.think.skills_cli import install_project -from solstone.think.user_config import config_path -from solstone.think.utils import get_journal, get_project_root - -logger = logging.getLogger(__name__) - - -def _version_marker_path() -> Path: - return config_path().parent / ".last-start-version" - - -def _version_marker_is_current(path: Path) -> bool: - try: - return path.read_text(encoding="utf-8") == f"{solstone.__version__}\n" - except FileNotFoundError: - return False - - -def _install_current_wrappers() -> None: - bin_dir = Path(sys.executable).parent - paths = alias_paths() - sol_bins = {binary: str(bin_dir / binary) for binary in paths} - install_wrappers(get_journal(), sol_bins, paths=paths) - - -def _refresh_skill_links() -> None: - report = install_project(Path(get_project_root()), Path(get_journal()), ["all"]) - if report.error_count: - raise RuntimeError(f"skill refresh failed with {report.error_count} error(s)") - - -def _refresh_for_version_marker(skip_reconcile: bool = False) -> None: - marker_path = _version_marker_path() - if _version_marker_is_current(marker_path): - return - - _install_current_wrappers() - if not skip_reconcile: - reconcile_installed_unit() - _refresh_skill_links() - atomic_replace(marker_path, f"{solstone.__version__}\n") - def main() -> None: - app_supervised = is_app_supervised(sys.argv) - try: - if not app_supervised: - reconcile_installed_unit() - _refresh_for_version_marker(skip_reconcile=app_supervised) - except Exception: - logger.exception("journal start failed during service reconciliation") - sys.exit(1) - from solstone.think import supervisor supervisor.main() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 44aad1459..23f26f328 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -364,6 +364,7 @@ def test_default_universal_battery_check_names(doctor): "sol_importable", "local_bin_sol_reachable", "stale_alias_symlink", + "skill_state", } @@ -877,6 +878,7 @@ def test_sol_doctor_subprocess_json_shape(): "sol_importable", "local_bin_sol_reachable", "stale_alias_symlink", + "skill_state", } @@ -907,6 +909,7 @@ def test_doctor_runs_with_minimal_path_env(tmp_path): "sol_importable", "local_bin_sol_reachable", "stale_alias_symlink", + "skill_state", } assert not any( name.startswith("service_") diff --git a/tests/test_journal_doctor.py b/tests/test_journal_doctor.py index c6e346170..9e8595a95 100644 --- a/tests/test_journal_doctor.py +++ b/tests/test_journal_doctor.py @@ -3,6 +3,7 @@ from __future__ import annotations +import os import plistlib from pathlib import Path from types import SimpleNamespace @@ -62,6 +63,29 @@ def patch_alias_absent(doctor, monkeypatch): ) +def tree_snapshot(root: Path) -> list[tuple[str, str, str]]: + snapshot: list[tuple[str, str, str]] = [] + for path in sorted(root.rglob("*")): + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + snapshot.append((rel, "symlink", os.readlink(path))) + elif path.is_file(): + snapshot.append((rel, "file", path.read_text(encoding="utf-8"))) + elif path.is_dir(): + snapshot.append((rel, "dir", "")) + return snapshot + + +def install_router_skill_links(doctor, journal: Path) -> None: + sources = doctor.skills_cli.discover_project_sources(doctor.ROOT) + for rel_dir in [Path(".claude/skills"), Path(".agents/skills")]: + skills_dir = journal / rel_dir + skills_dir.mkdir(parents=True) + for source in sources: + link = skills_dir / source.name + link.symlink_to(os.path.relpath(source, skills_dir)) + + def test_service_running_ok(doctor, monkeypatch): monkeypatch.setattr(doctor, "service_is_installed", lambda: True) monkeypatch.setattr(doctor, "fetch_supervisor_status", lambda: {"crashed": []}) @@ -202,12 +226,66 @@ def test_role_skip_without_local_journal(doctor, monkeypatch, tmp_path, home_roo assert by_name["journal_sync"].status == "skip" assert by_name["service_identity"].status == "skip" assert by_name["service_running"].status == "skip" + assert by_name["skill_state"].status == "skip" assert by_name["disk_space"].status in {"ok", "warn"} assert by_name["config_dir_readable"].status == "ok" assert by_name["feature:pdf"].status in {"ok", "warn"} assert by_name["feature:whisper"].status in {"ok", "warn"} +def test_skill_state_no_local_journal_skips(doctor, monkeypatch, tmp_path): + journal = tmp_path / "missing-journal" + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) + + result = doctor.skill_state_check(args(doctor)) + + assert result.status == "skip" + assert result.detail == "no local journal" + + +def test_skill_state_current_router_links_ok(doctor, monkeypatch, tmp_path): + journal = tmp_path / "journal" + journal.mkdir() + install_router_skill_links(doctor, journal) + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) + + result = doctor.skill_state_check(args(doctor)) + + assert result.status == "ok" + assert result.detail == "router skills sol, journal are installed and current" + + +def test_skill_state_warns_for_stale_and_missing_links_without_writing( + doctor, monkeypatch, tmp_path +): + journal = tmp_path / "journal" + skills_dir = journal / ".claude" / "skills" + skills_dir.mkdir(parents=True) + sources = { + source.name: source + for source in doctor.skills_cli.discover_project_sources(doctor.ROOT) + } + (skills_dir / "journal").symlink_to(os.path.relpath(sources["journal"], skills_dir)) + (skills_dir / "entities").symlink_to( + "../../../solstone/apps/entities/talent/entities" + ) + before = tree_snapshot(journal) + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) + + result = doctor.skill_state_check(args(doctor)) + + assert result.status == "warn" + assert f"missing router sol at {skills_dir / 'sol'}" in result.detail + assert f"stale skill link entities at {skills_dir / 'entities'}" in result.detail + assert result.fix is not None + assert "journal setup" in result.fix + assert f"sol skills install --project {journal} --agent all" in result.fix + assert tree_snapshot(journal) == before + + class TestJournalAlias: @pytest.fixture(autouse=True) def isolated_legacy_backups(self, doctor, monkeypatch, tmp_path): diff --git a/tests/test_journal_skill.py b/tests/test_journal_skill.py index 5910b428e..90cf7e3a4 100644 --- a/tests/test_journal_skill.py +++ b/tests/test_journal_skill.py @@ -48,6 +48,7 @@ def test_journal_skill_references_exist_and_linked(): "references/captures.md", "references/logs.md", "references/storage.md", + "references/commands.md", ] for rel_path in references: diff --git a/tests/test_journal_start.py b/tests/test_journal_start.py index 2a540bc79..fd81502a8 100644 --- a/tests/test_journal_start.py +++ b/tests/test_journal_start.py @@ -3,205 +3,25 @@ from __future__ import annotations -import sys -from pathlib import Path from unittest.mock import MagicMock -import pytest - from solstone.think import start -from solstone.think.app_supervised import FLAG, SELECTOR_ENV -from solstone.think.service import Reconciled - - -def _patch_marker(monkeypatch: pytest.MonkeyPatch, marker: Path) -> None: - monkeypatch.setattr(start, "_version_marker_path", lambda: marker) - - -def test_start_reconcile_idempotent_no_rewrite(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) - supervisor = MagicMock() - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) - - start.main() - - reconcile.assert_called_once_with() - supervisor.assert_called_once_with() - -def test_start_version_marker_mismatch_triggers_refresh(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text("old-version\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - calls: list[str] = [] - monkeypatch.setattr( - start, "_install_current_wrappers", lambda: calls.append("wrappers") - ) - monkeypatch.setattr( - start, - "reconcile_installed_unit", - lambda: calls.append("reconcile") or Reconciled(False, None, None, None), - ) - monkeypatch.setattr(start, "_refresh_skill_links", lambda: calls.append("skills")) - start._refresh_for_version_marker() - - assert calls == ["wrappers", "reconcile", "skills"] - assert marker.read_text(encoding="utf-8") == f"{start.solstone.__version__}\n" - - -@pytest.mark.parametrize("selector", ["flag", "env"]) -def test_app_supervised_start_skips_reconcile_but_refreshes_version_marker_artifacts( - selector, monkeypatch, tmp_path -): - marker = tmp_path / ".last-start-version" - marker.write_text("old-version\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.delenv(SELECTOR_ENV, raising=False) - argv = ["journal", "start"] - if selector == "flag": - argv.append(FLAG) - else: - monkeypatch.setenv(SELECTOR_ENV, "1") - monkeypatch.setattr(sys, "argv", argv) - - calls: list[str] = [] - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) +def test_start_invokes_supervisor(monkeypatch): supervisor = MagicMock() - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) - monkeypatch.setattr( - start, "_install_current_wrappers", lambda: calls.append("wrappers") - ) - monkeypatch.setattr(start, "_refresh_skill_links", lambda: calls.append("skills")) monkeypatch.setattr("solstone.think.supervisor.main", supervisor) start.main() - reconcile.assert_not_called() - assert calls == ["wrappers", "skills"] - assert marker.read_text(encoding="utf-8") == f"{start.solstone.__version__}\n" supervisor.assert_called_once_with() -def test_default_start_reconciles_both_sites_with_stale_marker(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text("old-version\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.delenv(SELECTOR_ENV, raising=False) - monkeypatch.setattr(sys, "argv", ["journal", "start"]) - - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) - supervisor = MagicMock() - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) - monkeypatch.setattr(start, "_refresh_skill_links", lambda: None) - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) - - start.main() - - assert reconcile.call_count == 2 - supervisor.assert_called_once_with() - - -@pytest.mark.skipif(sys.platform != "linux", reason="linux reconcile regression") -def test_default_start_reconciles_both_sites_with_stale_marker_on_linux( - monkeypatch, tmp_path -): - marker = tmp_path / ".last-start-version" - marker.write_text("old-version\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.delenv(SELECTOR_ENV, raising=False) - monkeypatch.setattr(sys, "argv", ["journal", "start"]) - - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) - monkeypatch.setattr(start, "_refresh_skill_links", lambda: None) - monkeypatch.setattr("solstone.think.supervisor.main", MagicMock()) - - start.main() - - assert reconcile.call_count == 2 - - -def test_start_version_marker_match_is_noop(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.setattr( - start, - "_install_current_wrappers", - lambda: pytest.fail("wrappers should not refresh"), - ) - monkeypatch.setattr( - start, - "reconcile_installed_unit", - lambda: pytest.fail("reconcile should not refresh"), - ) - monkeypatch.setattr( - start, - "_refresh_skill_links", - lambda: pytest.fail("skills should not refresh"), - ) - - start._refresh_for_version_marker() - - -def test_start_invokes_supervisor(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.setattr( - start, - "reconcile_installed_unit", - lambda: Reconciled(False, None, None, None), - ) - supervisor = MagicMock() - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) - - start.main() - - supervisor.assert_called_once_with() - - -def test_start_reconcile_failure_exits_nonzero(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.setattr( - start, - "reconcile_installed_unit", - MagicMock(side_effect=OSError("boom")), - ) - - with pytest.raises(SystemExit) as exc_info: - start.main() - - assert exc_info.value.code == 1 - - -def test_start_skill_refresh_error_exits_nonzero(monkeypatch, tmp_path): - marker = tmp_path / ".last-start-version" - marker.write_text("old-version\n", encoding="utf-8") - _patch_marker(monkeypatch, marker) - monkeypatch.setattr( - start, - "reconcile_installed_unit", - lambda: Reconciled(False, None, None, None), - ) - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) - monkeypatch.setattr( - start, - "_refresh_skill_links", - MagicMock(side_effect=RuntimeError("skill refresh failed")), - ) - - with pytest.raises(SystemExit) as exc_info: - start.main() +def test_start_exports_only_main_callable(): + callables = { + name + for name, value in vars(start).items() + if callable(value) and not name.startswith("__") + } - assert exc_info.value.code == 1 - assert marker.read_text(encoding="utf-8") == "old-version\n" + assert callables == {"main"} diff --git a/tests/test_setup.py b/tests/test_setup.py index eff3696b1..8d7f62a81 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -353,9 +353,9 @@ def prior_artifact_paths(journal: Path) -> dict[str, list[Path]]: "journal": [setup.config_path(), journal], "install_models": setup.model_paths(), "skills_user": [ - Path.home() / ".claude" / "skills" / "solstone" / "SKILL.md", - Path.home() / ".codex" / "skills" / "solstone" / "SKILL.md", - Path.home() / ".gemini" / "skills" / "solstone" / "SKILL.md", + Path.home() / ".claude" / "skills" / "sol" / "SKILL.md", + Path.home() / ".codex" / "skills" / "sol" / "SKILL.md", + Path.home() / ".gemini" / "skills" / "sol" / "SKILL.md", ], "skills_journal": [ journal / ".claude" / "skills", @@ -1702,7 +1702,7 @@ def test_setup_wrapper_round_trip_closure( assert state is install_guard.AliasState.OWNED -def test_step_skills_user_installs_solstone_bundle_for_all_agents( +def test_step_skills_user_installs_sol_skill_for_all_agents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_skills_build.py b/tests/test_skills_build.py new file mode 100644 index 000000000..abc6ec49d --- /dev/null +++ b/tests/test_skills_build.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import shutil +from pathlib import Path + +import frontmatter +import pytest + +from solstone.think import skills_build +from solstone.think.command_polarity import classify_verb + +REAL_ROOT = Path(__file__).resolve().parents[1] + + +def _copy_fragment_tree(tmp_path: Path) -> Path: + root = tmp_path / "repo" + for rel_path in skills_build.FRAGMENT_SOURCES.values(): + src = REAL_ROOT / rel_path + dst = root / rel_path + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return root + + +def _patch_root(monkeypatch: pytest.MonkeyPatch, root: Path) -> None: + monkeypatch.setattr(skills_build, "ROOT", root) + + +def _output_paths(root: Path) -> set[Path]: + return { + root / skills_build.SOL_COMMANDS_PATH, + root / skills_build.JOURNAL_COMMANDS_PATH, + } + + +def _section(content: str, heading: str) -> str: + start = content.index(heading) + next_heading = content.find("\n## ", start + 1) + if next_heading == -1: + return content[start:] + return content[start:next_heading] + + +def test_render_is_deterministic_and_build_check_is_current(monkeypatch, tmp_path): + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + + first = skills_build.render() + second = skills_build.render() + assert first == second + + written = skills_build.build() + assert set(written) == _output_paths(root) + assert skills_build.check() == [] + + before = {path: path.read_bytes() for path in written} + skills_build.build() + after = {path: path.read_bytes() for path in written} + assert before == after + + +def test_check_detects_staleness_without_writing(monkeypatch, tmp_path): + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + written = skills_build.build() + stale_path = written[0] + stale_path.write_text( + stale_path.read_text(encoding="utf-8") + "stale\n", encoding="utf-8" + ) + before = stale_path.read_text(encoding="utf-8") + + stale = skills_build.check() + + assert stale == [stale_path] + assert stale_path.read_text(encoding="utf-8") == before + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ("---\nname: [\n---\n", "malformed frontmatter"), + ("---\nname: wrong\ndescription: nope\n---\n", "does not match directory"), + ("---\nname: activities\n---\n", "missing frontmatter description"), + ], +) +def test_malformed_fragment_raises_with_path_without_partial_output( + monkeypatch, tmp_path, content, message +): + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + target = root / skills_build.FRAGMENT_SOURCES["activities"] + target.write_text(content, encoding="utf-8") + + with pytest.raises(ValueError) as exc_info: + skills_build.build() + + assert str(target) in str(exc_info.value) + assert message in str(exc_info.value) + for output in _output_paths(root): + assert not output.exists() + + +def test_duplicate_fragment_name_raises_with_path_without_partial_output( + monkeypatch, tmp_path +): + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + duplicate = Path("solstone/apps/entities-copy/talent/entities/SKILL.md") + duplicate_path = root / duplicate + duplicate_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(root / skills_build.FRAGMENT_SOURCES["entities"], duplicate_path) + sources = dict(skills_build.FRAGMENT_SOURCES) + sources["entities_copy"] = duplicate + monkeypatch.setattr(skills_build, "FRAGMENT_SOURCES", sources) + + with pytest.raises(ValueError) as exc_info: + skills_build.build() + + assert str(duplicate_path) in str(exc_info.value) + assert "duplicate app key 'entities'" in str(exc_info.value) + for output in _output_paths(root): + assert not output.exists() + + +def test_polarity_classification_and_rendered_other_group(monkeypatch, tmp_path): + assert classify_verb("search") == "read" + assert classify_verb("merge") == "write" + assert classify_verb("detect") == "other" + assert classify_verb("scan") == "read" + assert classify_verb("segments") == "other" + + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + content = skills_build.render()[str(root / skills_build.SOL_COMMANDS_PATH)] + health = _section(content, "## health") + assert "Other: `for-range`, `full`, `pipeline`, `summary`" in health + + +def test_health_contributes_to_both_router_references(monkeypatch, tmp_path): + root = _copy_fragment_tree(tmp_path) + _patch_root(monkeypatch, root) + + outputs = skills_build.render() + sol = outputs[str(root / skills_build.SOL_COMMANDS_PATH)] + journal = outputs[str(root / skills_build.JOURNAL_COMMANDS_PATH)] + + assert "## health — `sol call health`" in sol + assert "## health — `journal health`, `journal talent`" in journal + assert "Guidance: `solstone/apps/health/talent/health/SKILL.md`" in journal + + +def test_trigger_parsing_matches_fragment_description(): + post = frontmatter.load(REAL_ROOT / skills_build.FRAGMENT_SOURCES["activities"]) + + assert skills_build._parse_triggers(post.metadata["description"]) == ( + "activity", + "activities", + "work session", + "completed span", + "mute/unmute", + "activity record", + "meeting attendees", + ) diff --git a/tests/test_skills_cli.py b/tests/test_skills_cli.py index 488c396d7..c1ad8fcd4 100644 --- a/tests/test_skills_cli.py +++ b/tests/test_skills_cli.py @@ -4,13 +4,12 @@ from __future__ import annotations import os -import shutil import sys from pathlib import Path import pytest -from solstone.think import skills_cli +from solstone.think import skills_build, skills_cli from solstone.think.skills_cli import ( install_project, install_user, @@ -27,13 +26,14 @@ def _write_skill(path: Path, content: bytes | None = None) -> None: def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: - skill_dir = tmp_path / "solstone" + skill_dir = tmp_path / "sol" _write_skill(skill_dir, content) return skill_dir def _mini_project_repo(tmp_path: Path) -> Path: repo = tmp_path / "repo" + _write_skill(repo / "solstone" / "talent" / "sol") _write_skill(repo / "solstone" / "talent" / "journal") _write_skill(repo / "solstone" / "talent" / "routines") _write_skill(repo / "solstone" / "apps" / "foo" / "talent" / "bar") @@ -57,17 +57,13 @@ def test_install_user_creates_targets_for_present_agents(tmp_path): assert report.error_count == 0 source = repo / "SKILL.md" assert ( - home / ".claude" / "skills" / "solstone" / "SKILL.md" + home / ".claude" / "skills" / "sol" / "SKILL.md" ).read_bytes() == source.read_bytes() assert ( - home / ".codex" / "skills" / "solstone" / "SKILL.md" + home / ".codex" / "skills" / "sol" / "SKILL.md" ).read_bytes() == source.read_bytes() - assert {path.name for path in (home / ".claude" / "skills").iterdir()} == { - "solstone" - } - assert {path.name for path in (home / ".codex" / "skills").iterdir()} == { - "solstone" - } + assert {path.name for path in (home / ".claude" / "skills").iterdir()} == {"sol"} + assert {path.name for path in (home / ".codex" / "skills").iterdir()} == {"sol"} def test_install_user_creates_missing_codex_parent_dir(tmp_path): @@ -77,13 +73,13 @@ def test_install_user_creates_missing_codex_parent_dir(tmp_path): report = install_user(repo, home, ["codex"]) assert report.error_count == 0 - assert (home / ".codex" / "skills" / "solstone" / "SKILL.md").exists() + assert (home / ".codex" / "skills" / "sol" / "SKILL.md").exists() assert report.rows == [ skills_cli.ActionRow( "codex", - "solstone", + "sol", "installed", - home / ".codex" / "skills" / "solstone", + home / ".codex" / "skills" / "sol", ) ] @@ -95,13 +91,13 @@ def test_install_user_creates_missing_gemini_parent_dir(tmp_path): report = install_user(repo, home, ["gemini"]) assert report.error_count == 0 - assert (home / ".gemini" / "skills" / "solstone" / "SKILL.md").exists() + assert (home / ".gemini" / "skills" / "sol" / "SKILL.md").exists() assert report.rows == [ skills_cli.ActionRow( "gemini", - "solstone", + "sol", "installed", - home / ".gemini" / "skills" / "solstone", + home / ".gemini" / "skills" / "sol", ) ] @@ -114,7 +110,7 @@ def test_install_user_creates_all_three_when_none_exist(tmp_path): assert report.error_count == 0 for agent in [".claude", ".codex", ".gemini"]: - assert (home / agent / "skills" / "solstone" / "SKILL.md").exists() + assert (home / agent / "skills" / "sol" / "SKILL.md").exists() assert [row.action for row in report.rows] == [ "installed", "installed", @@ -131,15 +127,13 @@ def test_install_user_replaces_modified_source(tmp_path): report = install_user(repo, home, ["claude"]) assert report.error_count == 0 - assert ( - home / ".claude" / "skills" / "solstone" / "SKILL.md" - ).read_bytes() == b"second" + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").read_bytes() == b"second" def test_install_user_replaces_existing_regular_file_target(tmp_path): repo = _mini_user_repo(tmp_path, b"fresh") home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "solstone" + target = home / ".claude" / "skills" / "sol" target.mkdir(parents=True) (target / "SKILL.md").write_bytes(b"stale") @@ -152,7 +146,7 @@ def test_install_user_replaces_existing_regular_file_target(tmp_path): def test_install_user_replaces_stray_symlink_target(tmp_path): repo = _mini_user_repo(tmp_path) home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "solstone" + target = home / ".claude" / "skills" / "sol" target.parent.mkdir(parents=True) target.symlink_to(tmp_path / "whatever") @@ -167,7 +161,7 @@ def test_install_user_replaces_stray_symlink_target(tmp_path): def test_install_user_replaces_stray_regular_file_target(tmp_path): repo = _mini_user_repo(tmp_path) home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "solstone" + target = home / ".claude" / "skills" / "sol" target.parent.mkdir(parents=True) target.write_text("not a dir", encoding="utf-8") @@ -182,7 +176,7 @@ def test_install_user_replaces_stray_regular_file_target(tmp_path): def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): repo = _mini_user_repo(tmp_path) home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "solstone" + target = home / ".claude" / "skills" / "sol" target.mkdir(parents=True) target.chmod(0o500) try: @@ -200,15 +194,15 @@ def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): def test_uninstall_user_removes_only_bundle_dirs(tmp_path): repo = _mini_user_repo(tmp_path) home = _home(tmp_path, ".claude") - solstone = home / ".claude" / "skills" / "solstone" + sol = home / ".claude" / "skills" / "sol" hop = home / ".claude" / "skills" / "hop" - _write_skill(solstone) + _write_skill(sol) _write_skill(hop) report = uninstall_user(repo, home, ["claude"]) assert report.error_count == 0 - assert not solstone.exists() + assert not sol.exists() assert hop.exists() @@ -230,8 +224,23 @@ def test_install_user_agent_filter(tmp_path): report = install_user(repo, home, ["claude"]) assert report.error_count == 0 - assert (home / ".claude" / "skills" / "solstone").exists() - assert not (home / ".codex" / "skills" / "solstone").exists() + assert (home / ".claude" / "skills" / "sol").exists() + assert not (home / ".codex" / "skills" / "sol").exists() + + +def test_install_user_leaves_existing_solstone_bundle_untouched(tmp_path): + repo = _mini_user_repo(tmp_path) + home = _home(tmp_path, ".claude") + old_bundle = home / ".claude" / "skills" / "solstone" + old_bundle.mkdir(parents=True) + (old_bundle / "SKILL.md").write_bytes(b"old bundle") + + report = install_user(repo, home, ["claude"]) + + assert report.error_count == 0 + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() + assert (old_bundle / "SKILL.md").read_bytes() == b"old bundle" + assert all(row.skill != "solstone" for row in report.rows) def test_install_project_creates_symlinks(tmp_path): @@ -243,16 +252,14 @@ def test_install_project_creates_symlinks(tmp_path): assert report.error_count == 0 for agent_dir in [".claude", ".agents"]: link_parent = target / agent_dir / "skills" - for name in ["journal", "routines", "bar"]: + for name in ["journal", "sol"]: link = link_parent / name assert link.is_symlink() assert os.readlink(link) == os.path.relpath( - repo - / "solstone" - / ("talent" if name != "bar" else "apps/foo/talent") - / name, + repo / "solstone" / "talent" / name, link_parent, ) + assert {path.name for path in link_parent.iterdir()} == {"journal", "sol"} def test_install_project_idempotent(tmp_path): @@ -279,26 +286,46 @@ def test_install_project_cleans_stale_symlinks(tmp_path): repo = _mini_project_repo(tmp_path) target = tmp_path / "work" install_project(repo, target, ["all"]) - shutil.rmtree(repo / "solstone" / "talent" / "routines") + stale = target / ".claude" / "skills" / "entities" + stale.symlink_to( + os.path.relpath( + repo / "solstone" / "apps" / "foo" / "talent" / "bar", stale.parent + ) + ) report = install_project(repo, target, ["all"]) assert report.error_count == 0 - assert not (target / ".claude" / "skills" / "routines").exists() + assert not stale.exists() assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) -def test_install_project_dedupe_error(tmp_path): - repo = tmp_path / "repo" - _write_skill(repo / "solstone" / "talent" / "foo") - _write_skill(repo / "solstone" / "apps" / "x" / "talent" / "foo") +def test_install_project_preserves_obsolete_user_directory_with_warning(tmp_path): + repo = _mini_project_repo(tmp_path) + target = tmp_path / "work" + obsolete = target / ".claude" / "skills" / "entities" + obsolete.mkdir(parents=True) + (obsolete / "SKILL.md").write_bytes(b"user content") + + report = install_project(repo, target, ["all"]) + + assert (obsolete / "SKILL.md").read_bytes() == b"user content" + assert report.error_count == 0 + warning = next(row for row in report.rows if row.path == obsolete) + assert warning.action == "warning" + assert warning.skill == "entities" + assert warning.reason == "user content at stale target preserved" + + +def test_install_project_dedupe_error(monkeypatch, tmp_path): + repo = _mini_project_repo(tmp_path) + monkeypatch.setattr(skills_cli, "ROUTER_SKILL_NAMES", ("sol", "sol")) with pytest.raises(ValueError) as exc_info: install_project(repo, tmp_path / "work", ["all"]) message = str(exc_info.value) - assert str(repo / "solstone" / "talent" / "foo") in message - assert str(repo / "solstone" / "apps" / "x" / "talent" / "foo") in message + assert "duplicate skill name 'sol'" in message def test_install_project_agent_claude_only(tmp_path): @@ -384,10 +411,10 @@ def test_list_status_reports_installed_and_not_installed(tmp_path): rows = list_user_status(user_repo, home, ["all"]) - assert ("claude", "solstone", "installed") in { + assert ("claude", "sol", "installed") in { (row.agent, row.skill, row.state) for row in rows } - assert ("codex", "solstone", "not installed") in { + assert ("codex", "sol", "not installed") in { (row.agent, row.skill, row.state) for row in rows } @@ -419,8 +446,8 @@ def test_main_install_user_default(monkeypatch, tmp_path, capsys): captured = capsys.readouterr() assert exit_code == 0 - assert "installed claude solstone" in captured.out - assert (home / ".claude" / "skills" / "solstone" / "SKILL.md").exists() + assert "installed claude sol" in captured.out + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): @@ -442,7 +469,7 @@ def test_repo_root_resolution_works_from_arbitrary_cwd(monkeypatch, tmp_path): result = resolve_user_skill() - assert result.name == "solstone" + assert result.name == "sol" assert (result / "SKILL.md").is_file() @@ -455,7 +482,7 @@ def test_user_skill_missing_file_fails_loudly(monkeypatch, tmp_path, capsys): with pytest.raises(FileNotFoundError) as exc_info: resolve_user_skill() - assert "solstone/talent/solstone/SKILL.md" in str(exc_info.value) + assert "solstone/talent/sol/SKILL.md" in str(exc_info.value) monkeypatch.setenv("HOME", str(home)) monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) @@ -466,5 +493,44 @@ def test_user_skill_missing_file_fails_loudly(monkeypatch, tmp_path, capsys): captured = capsys.readouterr() assert exit_code == 1 assert "error:" in captured.err - assert "solstone/talent/solstone/SKILL.md" in captured.err + assert "solstone/talent/sol/SKILL.md" in captured.err assert "Traceback" not in captured.err + + +def test_main_build_generates_references(monkeypatch, tmp_path, capsys): + output = tmp_path / "commands.md" + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) + monkeypatch.setattr(skills_build, "build", lambda: [output]) + monkeypatch.setattr(sys, "argv", ["sol skills", "build"]) + + exit_code = skills_cli.main() + + captured = capsys.readouterr() + assert exit_code == 0 + assert f"generated {output}" in captured.out + + +def test_main_build_check_green(monkeypatch, tmp_path, capsys): + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) + monkeypatch.setattr(skills_build, "check", lambda: []) + monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) + + exit_code = skills_cli.main() + + captured = capsys.readouterr() + assert exit_code == 0 + assert "generated skill references are current" in captured.out + + +def test_main_build_check_reports_stale_path(monkeypatch, tmp_path, capsys): + stale = tmp_path / "commands.md" + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) + monkeypatch.setattr(skills_build, "check", lambda: [stale]) + monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) + + exit_code = skills_cli.main() + + captured = capsys.readouterr() + assert exit_code == 1 + assert str(stale) in captured.err + assert "run `sol skills build`" in captured.err diff --git a/tests/test_sol_service_hard_error.py b/tests/test_sol_service_hard_error.py index 626d0fbcf..adbde4fa6 100644 --- a/tests/test_sol_service_hard_error.py +++ b/tests/test_sol_service_hard_error.py @@ -108,9 +108,8 @@ def test_stale_sol_unit_execs_journal(monkeypatch): with pytest.raises(RuntimeError, match="execv called"): sol_cli.main() - # Shim routes through `journal start` (the canonical refresh-doing entry), - # not `journal supervisor`, so the version-marker / wrapper / skill refresh - # fires on this boot rather than waiting for the next restart. + # Shim routes through `journal start`, the canonical service entry point, + # not directly through `journal supervisor`. execv.assert_called_once_with("/tmp/journal", ["/tmp/journal", "start", "5015"]) -- 2.51.2