personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor(skills): collapse installed agent skills into two routers (sol, journal)

- 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) <noreply@anthropic.com>

+1203 -672
+3 -3
AGENTS.md
··· 5 5 Audience: 6 6 7 7 - **Coders** (cwd = repo root, editing `solstone/observe/`, `solstone/think/`, `solstone/convey/`, `solstone/apps/`, `solstone/talent/`, `tests/`) — you're in the right place. 8 - - **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`. 8 + - **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`. 9 9 - **Operators** debugging a running system — see `docs/DOCTOR.md`. 10 10 11 11 For the journal-side runtime entry point, see `journal/AGENTS.md`. ··· 38 38 | `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` | 39 39 | `solstone/convey/` | Web app framework — app discovery, routing, bridge | layout / framework-level UI changes | `docs/CONVEY.md` | 40 40 | `solstone/apps/` | Convey apps — each self-contained (`call.py` Typer sub-app + `routes.py` + `templates/`) | adding a user-facing feature, a `sol call <app>` verb, a UI surface | `docs/APPS.md` (required reading before modifying `solstone/apps/`) | 41 - | `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` | 41 + | `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` | 42 42 | `scripts/` | Repo maintenance scripts — `check_layer_hygiene.py` | tooling that guards the codebase; wired into `make ci` | (none) | 43 43 | `tests/` | Pytest suites + `tests/fixtures/journal/` mock journal | writing tests; debugging flakiness; `make dev` / `make sandbox` use fixtures as the journal | `docs/testing.md` | 44 44 | `docs/` | All longform documentation | reference lookups; never your first stop | §10 below | ··· 88 88 | Target | When to use | 89 89 |--------|-------------| 90 90 | `make install` | First setup and whenever `pyproject.toml` or `uv.lock` changes. Creates `.venv/`, syncs deps, runs `make skills`. | 91 - | `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.) | 91 + | `make skills` | Regenerate generated router references, then rewrite the `sol` + `journal` router skill symlinks into `journal/`. (`make install` depends on this; rarely run alone.) | 92 92 | `make update` | Upgrade all deps to latest, regenerate `uv.lock`. Expect test churn. | 93 93 | `make update-prices` | Refresh genai-prices model-cost data when adding a new provider model or when pricing tests fail. | 94 94 | `make clean` | Remove build artifacts, caches, and the skill symlinks. Does not touch `.venv/`. |
+6 -4
CONTRIBUTING.md
··· 53 53 .venv/bin/journal setup 54 54 ``` 55 55 56 - `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. 56 + `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. 57 57 58 - `.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. 58 + `.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. 59 59 60 60 After the first setup run, the wrapper lets you use `sol` from anywhere: 61 61 ··· 156 156 157 157 Talent prompts live under `solstone/talent/<name>.md`; apps may add app-specific talent files under `solstone/apps/<app>/talent/`. Talent frontmatter declares type, schedule, provider/model behavior, hooks, priority, and output expectations. 158 158 159 - Skills are `SKILL.md` files under `solstone/talent/` or `solstone/apps/*/talent/`. After adding or renaming a skill, run: 159 + The installed project skills are the two router skills under `solstone/talent/sol/` and `solstone/talent/journal/`. App command fragments under `solstone/apps/<app>/talent/<app>/SKILL.md` feed the generated router references; they are not installed as top-level skills. 160 + 161 + After changing a router skill or an app command fragment, run: 160 162 161 163 ```bash 162 164 make skills 163 165 ``` 164 166 165 - That refreshes the `.claude/` and `.agents/` skill symlinks inside the journal. `make install` also runs this target. 167 + 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. 166 168 167 169 ## Migrating from a source install to a packaged install 168 170
+2 -2
INSTALL.md
··· 39 39 journal setup 40 40 ``` 41 41 42 - 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. 42 + 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. 43 43 44 44 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. 45 45 ··· 97 97 98 98 1. remove setup-managed runtime files: `journal setup --clean-uninstall` 99 99 this removes the user service, managed `~/.local/bin/sol` wrapper, user config, and setup manifest. it does not remove your journal. 100 - 2. optional: remove agentic-tooling skills: `sol skills uninstall`. 100 + 2. optional: remove the installed `sol` agent skill: `sol skills uninstall`. 101 101 3. uninstall the python package: `uv tool uninstall solstone` (or `pipx uninstall solstone`). 102 102 4. macOS only: drag `/Applications/solstone.app` to Trash. 103 103 5. macOS only, optional: remove observer app data and the parakeet model cache:
+8
Makefile
··· 127 127 128 128 # Setup skill symlinks 129 129 skills: 130 + @$(VENV_BIN)/sol skills build 130 131 @$(VENV_BIN)/sol skills install --project journal --agent all 131 132 132 133 # Start local dev stack against fixture journal (no observers, no daily processing) ··· 403 404 @echo "=== Running cogitate-prompt check ===" 404 405 @$(MAKE) check-cogitate-prompts 405 406 @echo "" 407 + @echo "=== Checking generated skill references ===" 408 + @$(MAKE) check-skill-references 409 + @echo "" 406 410 @echo "=== Checking extras consistency ===" 407 411 @$(VENV_BIN)/python scripts/check_extras_consistency.py 408 412 @echo "" ··· 488 492 # Cogitate-prompt static gate (prompts use only on-contract command forms) 489 493 check-cogitate-prompts: .installed 490 494 $(VENV_BIN)/python scripts/check_cogitate_prompts.py 495 + 496 + # Generated router skill references gate 497 + check-skill-references: .installed 498 + $(VENV_BIN)/sol skills build --check 491 499 492 500 # Re-run the live four-backend integrated-façade cogitate smoke. Spawns the 493 501 # archived runner (extro `vpe/workspace/archived/`) against this venv so the
+17 -19
docs/APPS.md
··· 408 408 409 409 --- 410 410 411 - ### 10. `solstone/talent/` - Agent Skills 411 + ### 10. Router Skills and App Command Fragments 412 412 413 - 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. 413 + 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/<app>/talent/<app>/` are command-guidance fragments. They are not installed directly; `sol skills build` folds them into the generated router references. 414 414 415 415 **Key Points:** 416 - - Create a subdirectory in `solstone/talent/` with a `SKILL.md` file (YAML frontmatter + markdown body) 417 - - The directory name must match the `name` field in the YAML frontmatter 418 - - Skill names must be unique across system `solstone/talent/` and all `solstone/apps/*/talent/` directories 419 - - `make skills` discovers all skills and symlinks them into `journal/.agents/skills/` and `journal/.claude/skills/` 420 - - Skills are standalone — they don't interact with the talent agent/generator system 421 - - The talent loader ignores subdirectories, so skills won't interfere with agent discovery 416 + - Installed router skill directories live at `solstone/talent/sol/` and `solstone/talent/journal/`. 417 + - App command fragments live at `solstone/apps/<app>/talent/<app>/SKILL.md`. 418 + - The fragment directory name must match the `name` field in the YAML frontmatter. 419 + - `make skills` runs `sol skills build`, then installs only the `sol` and `journal` router skill symlinks into `journal/.agents/skills/` and `journal/.claude/skills/`. 420 + - `make ci` / `make install-checks` runs `sol skills build --check` and fails if generated references are stale. 421 + - Router skills and app fragments are standalone from the talent agent/generator system; the talent loader ignores subdirectories. 422 422 423 - **Directory structure:** 423 + **Router skill directory structure:** 424 424 ``` 425 - talent/my-skill/ 425 + talent/sol/ 426 426 ├── SKILL.md # Required: YAML frontmatter + instructions 427 - ├── scripts/ # Optional: Executable code (Python, Bash, etc.) 428 - ├── references/ # Optional: Additional documentation loaded on demand 429 - └── assets/ # Optional: Static resources (templates, data files) 427 + └── references/ 428 + └── commands.md # Generated by sol skills build 430 429 ``` 431 430 432 431 **SKILL.md format:** 433 432 ```yaml 434 433 --- 435 - name: my-skill 434 + name: sol 436 435 description: Short description of what this skill does and when to use it. 437 436 --- 438 437 ··· 451 450 - `metadata` — Arbitrary key-value string map 452 451 - `allowed-tools` — Space-delimited list of pre-approved tools (experimental) 453 452 454 - **App skills** work the same way — place a skill directory inside `solstone/apps/my_app/talent/`: 453 + **App command fragments** use the same frontmatter shape and live under `solstone/apps/my_app/talent/`: 455 454 ``` 456 - apps/my_app/talent/my-skill/ 457 - ├── SKILL.md 458 - └── references/ 455 + apps/my_app/talent/my_app/ 456 + └── SKILL.md 459 457 ``` 460 458 461 - **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. 459 + **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/`. 462 460 463 461 --- 464 462
+26 -23
docs/SOLCLI.md
··· 132 132 133 133 2. **That's it for the CLI.** Auto-discovery picks it up on next run. 134 134 135 - 3. **Create the agent skill** (if agents should use these commands): 135 + 3. **Update the app command fragment** (if agents should use these commands): 136 136 137 137 ```markdown 138 138 # solstone/apps/myapp/talent/myapp/SKILL.md 139 139 --- 140 140 name: myapp 141 141 description: > 142 - What this skill does. When to trigger it. 142 + What this command fragment covers. When to trigger it. 143 143 TRIGGER: keyword1, keyword2, keyword3. 144 144 --- 145 145 146 - # MyApp CLI Skill 146 + # MyApp CLI Fragment 147 147 148 148 Common pattern: 149 149 \`\`\`bash ··· 162 162 - `-f, --facet`: facet name (default: `SOL_FACET` env). 163 163 ``` 164 164 165 - 4. **Run `sol skills install --project`** to create the symlink in `journal/.agents/skills/` (`make skills` wraps this). 165 + 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. 166 166 167 - 5. **Update AGENTS.md** — add the skill to the Skills table. 167 + 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. 168 168 169 169 ### Local-only think tools 170 170 ··· 173 173 174 174 1. **Create `solstone/think/tools/<name>.py`** with `app = typer.Typer()` and a `main()` that calls `app()`. 175 175 2. **Register in `solstone/think/sol_cli.py`** with `surface="service"`. 176 - 3. **Optionally create a skill** in `solstone/talent/<name>/SKILL.md`. 176 + 3. **Optionally update a router skill reference** if the command needs agent-facing guidance. 177 177 178 178 ### Files to maintain for a new call command 179 179 180 180 | File | What to do | Required? | 181 181 |------|-----------|-----------| 182 182 | `solstone/apps/<name>/call.py` | Typer app with commands | Yes | 183 - | `solstone/apps/<name>/talent/<name>/SKILL.md` | Skill doc for agents | If agents should use it | 184 - | `journal/.agents/skills/<name>` | Symlink (via `sol skills install --project`; `make skills` wrapper) | Auto-generated | 185 - | `AGENTS.md` Skills table | Add trigger description | If skill exists | 183 + | `solstone/apps/<name>/talent/<name>/SKILL.md` | App command guidance fragment used by `sol skills build` | If agents should use it | 184 + | `solstone/talent/sol/references/commands.md` | Generated `sol call <app>` inventory | Auto-generated by `sol skills build` | 185 + | `solstone/talent/journal/references/commands.md` | Generated journal-host command guidance | Auto-generated by `sol skills build` | 186 186 | `tests/test_<name>_call.py` | CLI tests | Yes | 187 187 188 188 ## Conventions ··· 371 371 ├── solstone/apps/ 372 372 │ ├── activities/ 373 373 │ │ ├── call.py # sol call activities (auto-discovered) 374 - │ │ └── talent/calendar/SKILL.md 374 + │ │ └── talent/activities/SKILL.md # builder source for generated router refs 375 375 │ ├── entities/call.py 376 376 │ ├── speakers/call.py 377 377 │ ├── support/call.py ··· 380 380 │ ├── awareness/call.py 381 381 │ └── ... (web-only apps without call.py) 382 382 ├── talent/ 383 - │ ├── journal/SKILL.md # Skills not tied to an app 384 - │ ├── vit/SKILL.md 383 + │ ├── sol/SKILL.md # installed sol router skill 384 + │ ├── journal/SKILL.md # installed journal router skill 385 + │ ├── sol/references/commands.md # generated app command inventory 386 + │ ├── journal/references/commands.md # generated journal-host command guidance 385 387 │ └── *.md # Agent prompt files 386 - ├── journal/.agents/skills/ # Symlinks (generated by sol skills install --project; make skills wrapper) 387 - └── AGENTS.md # Sol identity + skill table 388 + ├── journal/.agents/skills/ # sol + journal router symlinks 389 + └── AGENTS.md # Developer guide 388 390 ``` 389 391 390 392 ### The `solstone/apps/` dual role ··· 424 426 | `awareness` | `solstone/apps/awareness/call.py` | status, imports, log, log-read | 425 427 | `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 | 426 428 427 - `sol skills` manages coding-agent skill installation. 429 + `sol skills` builds generated router references and manages coding-agent skill installation. 428 430 429 431 ## Skill System 430 432 431 - 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. 433 + 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. 432 434 433 435 **Skill locations:** 434 - - App skills: `solstone/apps/<name>/talent/<name>/SKILL.md` 435 - - Core skills: `solstone/talent/<name>/SKILL.md` 436 + - Installed router skills: `solstone/talent/sol/SKILL.md`, `solstone/talent/journal/SKILL.md` 437 + - App command fragments: `solstone/apps/<name>/talent/<name>/SKILL.md` 438 + - Generated references: `solstone/talent/sol/references/commands.md`, `solstone/talent/journal/references/commands.md` 436 439 437 - **Skill ≠ call command.** Not every skill has a corresponding `call.py`, and not every `call.py` has a skill: 438 - - `health` and `vit` have skills but no `call.py` 439 - - Some call apps provide the CLI while the skill provides agent behavioral context 440 + 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. 440 441 441 - 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"). 442 + 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. 442 443 443 444 ### Keeping skills in sync 444 445 445 - 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: 446 + 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: 446 447 - Full command syntax with all flags 447 448 - Behavior notes (edge cases, defaults, validation) 448 449 - Examples showing common usage patterns 450 + 451 + 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.
+5 -5
docs/project-structure.md
··· 9 9 │ └── sol_cli.py # Unified CLI entry point (run: sol <command>) 10 10 ├── convey/ # Web app frontend & backend 11 11 ├── solstone/apps/ # Convey app extensions (see docs/APPS.md) 12 - ├── talent/ # Agent/generator configs + Agent Skills (talent/*/SKILL.md) 12 + ├── talent/ # Agent/generator configs + sol/journal router skills 13 13 ├── tests/ # Pytest test suites + test fixtures under tests/fixtures/ 14 14 ├── docs/ # All documentation (*.md files) 15 15 ├── AGENTS.md # Development guidelines (this file) ··· 34 34 35 35 ## Agent & Skill Organization 36 36 37 - `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`. 37 + `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. 38 38 39 39 ## File Locations 40 40 41 41 - **Entry Points**: `solstone/think/sol_cli.py` `COMMANDS` dict 42 42 - **Test Fixtures**: `tests/fixtures/journal/` - complete mock journal 43 43 - **Live Logs**: `journal/health/<service>.log` 44 - - **Agent Personas**: `solstone/talent/*.md` (apps can add their own in `solstone/talent/`, see [docs/APPS.md](docs/APPS.md)) 45 - - **Generator Templates**: `solstone/talent/*.md` (apps can add their own in `solstone/talent/`, see [docs/APPS.md](docs/APPS.md)) 46 - - **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 44 + - **Agent Personas**: `solstone/talent/*.md` (apps can add their own talent files under `solstone/apps/*/talent/`, see [docs/APPS.md](docs/APPS.md)) 45 + - **Generator Templates**: `solstone/talent/*.md` (apps can add their own talent files under `solstone/apps/*/talent/`, see [docs/APPS.md](docs/APPS.md)) 46 + - **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` 47 47 - **Scratch Space**: `scratch/` - git-ignored local workspace
+1 -1
journal/AGENTS.md
··· 2 2 3 3 > **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. 4 4 5 - 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`. 5 + 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 <app>` guidance, use the `sol` router's generated `references/commands.md`.
-2
scripts/check_journal_io_access.py
··· 114 114 "solstone/think/journal_config.py", 115 115 "solstone/think/schedule_config.py", 116 116 "solstone/think/push/devices.py", 117 - # Host-config mechanic only; writes ~/.config/solstone/.last-start-version, not journal domain state. 118 - "solstone/think/start.py", 119 117 # Link domain — device-pairing service state. 120 118 "solstone/think/link/auth.py", 121 119 "solstone/think/link/ca.py",
+2 -1
scripts/check_journal_io_mechanic.py
··· 114 114 "solstone/observe/transcribe/_parakeet_coreml.py", 115 115 "solstone/observe/transcribe/revai.py", 116 116 "solstone/think/journal_export.py", 117 - # UI/pipeline runtime state, not owner journal content. 117 + # UI/pipeline runtime state and generated source artifacts, not owner journal content. 118 118 "solstone/apps/home/routes.py", 119 119 "solstone/apps/transcripts/routes.py", 120 120 "solstone/think/data_state.py", 121 121 "solstone/think/skills_cli.py", 122 + "solstone/think/skills_build.py", 122 123 } 123 124 ) 124 125
+3 -33
scripts/check_layer_hygiene.py
··· 32 32 import sys 33 33 from pathlib import Path 34 34 35 + from solstone.think.command_polarity import is_read_verb 36 + 35 37 ROOT = Path(__file__).resolve().parent.parent 36 38 37 39 # Module families scrutinized as "infrastructure" per L1/L6/L7. ··· 70 72 ), 71 73 ) 72 74 73 - # Read verbs per docs/coding-standards.md § L3. Match against any 74 - # underscore-split segment of the function name. 75 - READ_VERBS: frozenset[str] = frozenset( 76 - { 77 - "load", 78 - "get", 79 - "read", 80 - "scan", 81 - "list", 82 - "show", 83 - "find", 84 - "match", 85 - "resolve", 86 - "query", 87 - "lookup", 88 - "status", 89 - "check", 90 - "validate", 91 - "discover", 92 - "format", 93 - "render", 94 - "extract", 95 - "parse", 96 - "view", 97 - "inspect", 98 - "info", 99 - "describe", 100 - "search", 101 - } 102 - ) 103 - 104 75 # Temporary, file-scoped exceptions for known layer-hygiene violations. 105 76 # Keep this empty by default; add entries only with a tracking identifier 106 77 # and remove them in the same bundle that fixes the violation. ··· 153 124 154 125 155 126 def has_read_verb(name: str) -> bool: 156 - base = name.lstrip("_") 157 - return any(part in READ_VERBS for part in base.split("_") if part) 127 + return is_read_verb(name) 158 128 159 129 160 130 def check_call_py(rel: Path, source: str) -> list[tuple[int, str, str]]:
+1
solstone/talent/journal/SKILL.md
··· 63 63 - [Captures and Extracts](references/captures.md) — layers, imports, segment layout 64 64 - [Logs](references/logs.md) — action logs, token usage, talent logs, health 65 65 - [Storage](references/storage.md) — top-level layout, app storage, search index 66 + - [Commands](references/commands.md) — journal-host command guidance contributed by apps (generated)
+11
solstone/talent/journal/references/commands.md
··· 1 + <!-- GENERATED by sol skills build. Do not hand-edit; regenerate with make skills. --> 2 + 3 + # Journal Router Commands 4 + 5 + Generated app-contributed command guidance for the `journal` router. 6 + 7 + ## health — `journal health`, `journal talent` 8 + 9 + Triggers: `health`, `status`, `is it running`, `service down`, `errors`, `agent runs`, `logs`, `pipeline`, `journal health`, `journal talent logs` 10 + 11 + Guidance: `solstone/apps/health/talent/health/SKILL.md`
+169
solstone/talent/sol/SKILL.md
··· 1 + --- 2 + name: sol 3 + version: 1.0.0 4 + description: > 5 + Read-only query of the solstone journal from any project, plus routing 6 + guidance for app-specific `sol call <app>` commands. Look up people and 7 + relationships, today's events; read transcripts. TRIGGER: solstone, my 8 + journal, search my memory, what happened, who is, meeting with, co-brain, 9 + recall, sol call routing, sol call journal/entities/transcripts. 10 + --- 11 + 12 + # sol — journal query router 13 + 14 + Read-only query interface to your solstone journal. Invoke via Bash: 15 + `sol call <app> <verb> [flags]`. 16 + 17 + Use this skill to search memories, look up people, check today's events, read 18 + transcripts, and choose the right `sol call` app command from outside the 19 + solstone project context. 20 + 21 + ## Prerequisites 22 + 23 + The `sol` CLI must be on PATH. Quick check: 24 + 25 + ```bash 26 + sol help 27 + ``` 28 + 29 + If this fails, solstone is not installed. Install it from the solstone project: 30 + `journal setup`. 31 + 32 + ## Capabilities 33 + 34 + ### recall — search your memory 35 + 36 + Search the journal index for anything matching a query. 37 + 38 + ```bash 39 + sol call journal search "<query>" 40 + sol call journal search "<query>" --day 20260327 41 + sol call journal search "<query>" --facet work 42 + sol call journal search "<query>" --day-from 20260320 --day-to 20260327 43 + ``` 44 + 45 + Dates use `YYYYMMDD` format. Omit `--day` to search all days. Omit `--facet` to 46 + search all facets. 47 + 48 + ### who — entities 49 + 50 + Look up what the journal knows about a person, company, or project. 51 + 52 + ```bash 53 + sol call entities search --query "<query>" 54 + sol call entities observations "<entity_name>" --facet "<facet>" 55 + ``` 56 + 57 + ### today — what's happening now 58 + 59 + Combine these commands to get a picture of the current day: 60 + 61 + ```bash 62 + sol call activities list --source anticipated 63 + sol call journal news "<facet>" 64 + ``` 65 + 66 + ### transcript — meeting transcripts 67 + 68 + Read what was said during meetings or any recorded time. 69 + 70 + ```bash 71 + sol call transcripts scan 72 + sol call transcripts scan 20260327 73 + sol call transcripts read 74 + sol call transcripts read 20260327 --start 140000 --length 120 75 + sol call transcripts stats 76 + ``` 77 + 78 + `scan` first to see what's available, then `read` with `--start` (HHMMSS, 79 + 24-hour format) and `--length` (minutes) to narrow down. 80 + 81 + ### status — system health 82 + 83 + Check if solstone is running and how much data exists. 84 + 85 + ```bash 86 + sol call journal storage-summary 87 + sol call support diagnose 88 + ``` 89 + 90 + ## Per-app command map 91 + 92 + See [Commands](references/commands.md) for the generated inventory of every 93 + `sol call <app>` command contributed by app skill fragments, including triggers 94 + and read/write/other polarity. Regenerate it with `sol skills build` or 95 + `make skills`; do not inline those command tables here. 96 + 97 + ## Paths 98 + 99 + `sol root` prints the solstone repo root, useful for scripting: 100 + `cd $(sol root)`. 101 + 102 + ## Environment 103 + 104 + The `sol` CLI uses three environment variables that default sensibly: 105 + 106 + - `SOL_DAY` — defaults to today (`YYYYMMDD`) 107 + - `SOL_FACET` — defaults to all facets 108 + - `SOL_SEGMENT` — defaults to no segment 109 + 110 + External callers should not need to set these. Use explicit flags (`--day`, 111 + `--facet`) when narrowing scope is needed. 112 + 113 + ## Composing queries 114 + 115 + **"Brief me on today"** — events plus active relationships: 116 + 117 + ```bash 118 + sol call activities list --source anticipated 119 + sol call entities search --limit 5 120 + ``` 121 + 122 + **"Prep me for a meeting with X"** — recent transcript mentions: 123 + 124 + ```bash 125 + sol call journal search "<name>" --day-from 20260320 126 + ``` 127 + 128 + **"What did I miss yesterday?"** — yesterday's events, transcripts, and news: 129 + 130 + ```bash 131 + sol call journal search "" -d 20260326 -a meetings 132 + sol call transcripts scan 20260326 133 + sol call journal news "<facet>" --day 20260326 134 + ``` 135 + 136 + ## Output format 137 + 138 + Most commands output plain text by default. Many support `--json` for structured 139 + output. Prefer plain text for human-readable answers; use `--json` when you need 140 + to process the data further. 141 + 142 + ## What you cannot do 143 + 144 + This is a read-only interface. The journal is the person's private space. You 145 + cannot: 146 + 147 + - Create, delete, or modify facets 148 + - Attach or modify entities 149 + - Write news or observations 150 + - Run pipeline operations (think, indexer, transcribe) 151 + - Access internal agent state or orchestration 152 + 153 + If a task requires writing to the journal, it must be done from within the 154 + solstone project context using sol's internal skills. 155 + 156 + ## Error handling 157 + 158 + If `sol` is not found on PATH or returns an error: 159 + 160 + - `"command not found: sol"` — solstone is not installed. The user needs to run 161 + `journal setup` in their solstone project. 162 + - `"journal not found"` or empty output — the journal directory doesn't exist or 163 + has no data yet. solstone may be installed but not initialized. 164 + - Connection errors from `sol call support` — every support command needs the 165 + local solstone service reachable. Portal-backed commands (`search`, `article`) 166 + can additionally fail when the journal host is offline. 167 + 168 + Do not retry failed commands. Report the error clearly so the user can 169 + investigate.
+71
solstone/talent/sol/references/commands.md
··· 1 + <!-- GENERATED by sol skills build. Do not hand-edit; regenerate with make skills. --> 2 + 3 + # Sol Router Commands 4 + 5 + Generated inventory of app command guidance for the `sol` router. 6 + 7 + ## activities — `sol call activities` 8 + 9 + Triggers: `activity`, `activities`, `work session`, `completed span`, `mute/unmute`, `activity record`, `meeting attendees` 10 + 11 + Read: `get`, `list` 12 + 13 + Write: `create`, `update` 14 + 15 + Other: `mute`, `unmute` 16 + 17 + Guidance: `solstone/apps/activities/talent/activities/SKILL.md` 18 + 19 + ## entities — `sol call entities` 20 + 21 + Triggers: `entity`, `person`, `company`, `relationship`, `who is`, `contact` 22 + 23 + Read: `list`, `search` 24 + 25 + Write: `accept-merge-candidate`, `attach`, `consolidate`, `dismiss-merge-candidate`, `merge`, `merge-candidates`, `move`, `record-merge-candidate`, `update` 26 + 27 + Other: `aka`, `detect`, `observations`, `observe` 28 + 29 + Guidance: `solstone/apps/entities/talent/entities/SKILL.md` 30 + 31 + ## health — `sol call health` 32 + 33 + Triggers: `health`, `status`, `is it running`, `service down`, `errors`, `agent runs`, `logs`, `pipeline`, `journal health`, `journal talent logs` 34 + 35 + Other: `for-range`, `full`, `pipeline`, `summary` 36 + 37 + Guidance: `solstone/apps/health/talent/health/SKILL.md` 38 + 39 + ## speakers — `sol call speakers` 40 + 41 + Triggers: `speaker`, `voice`, `who was talking`, `identify speaker`, `voiceprint` 42 + 43 + Read: `discover`, `resolve-names`, `status` 44 + 45 + Write: `backfill`, `backfill-last-seen`, `bootstrap`, `link-import`, `merge-names`, `seed-from-imports` 46 + 47 + Other: `attribute-segment`, `confirm-owner`, `detect`, `identify`, `owner-ready`, `reject-owner`, `suggest`, `wipe` 48 + 49 + Guidance: `solstone/apps/speakers/talent/speakers/SKILL.md` 50 + 51 + ## support — `sol call support` 52 + 53 + Triggers: `file bug`, `request feature`, `submit feedback`, `search KB`, `announcements`, `tickets` 54 + 55 + Read: `list`, `search`, `show` 56 + 57 + Write: `attach`, `create` 58 + 59 + Other: `announcements`, `article`, `diagnose`, `feedback`, `register`, `reply` 60 + 61 + Guidance: `solstone/apps/support/talent/support/SKILL.md` 62 + 63 + ## transcripts — `sol call transcripts` 64 + 65 + Triggers: `transcript`, `recording`, `audio`, `what was said`, `conversation`, `segment`, `screen capture` 66 + 67 + Read: `read`, `scan` 68 + 69 + Other: `segments`, `stats` 70 + 71 + Guidance: `solstone/apps/transcripts/talent/transcripts/SKILL.md`
-158
solstone/talent/solstone/SKILL.md
··· 1 - --- 2 - name: solstone 3 - version: 1.0.0 4 - description: > 5 - Read-only query of the solstone journal from any project. Look up people 6 - and relationships, today's events; read transcripts. TRIGGER: 7 - solstone, my journal, search my memory, what happened, who is, meeting 8 - with, co-brain, recall, sol call journal/entities/transcripts. 9 - --- 10 - 11 - # solstone — journal query interface 12 - 13 - Read-only query interface to your solstone journal. Invoke via Bash: `sol call <subcommand> [flags]`. 14 - 15 - Use this skill to search memories, look up people, check today's events, and read transcripts — all from any project context. 16 - 17 - ## Prerequisites 18 - 19 - The `sol` CLI must be on PATH. Quick check: 20 - 21 - ```bash 22 - sol help 23 - ``` 24 - 25 - If this fails, solstone is not installed. Install it from the solstone project: `journal setup`. 26 - 27 - ## Capabilities 28 - 29 - ### recall — search your memory 30 - 31 - Search the journal index for anything matching a query. 32 - 33 - ```bash 34 - sol call journal search "<query>" 35 - sol call journal search "<query>" --day 20260327 36 - sol call journal search "<query>" --facet work 37 - sol call journal search "<query>" --day-from 20260320 --day-to 20260327 38 - ``` 39 - 40 - Dates use `YYYYMMDD` format. Omit `--day` to search all days. Omit `--facet` to search all facets. 41 - 42 - ### who — entities 43 - 44 - Look up what the journal knows about a person, company, or project. 45 - 46 - ```bash 47 - # Search for entities by text, type, or activity 48 - sol call entities search --query "<query>" 49 - 50 - # See observations recorded for an entity (requires facet) 51 - sol call entities observations "<entity_name>" --facet "<facet>" 52 - ``` 53 - 54 - ### today — what's happening now 55 - 56 - Combine these commands to get a full picture of the current day: 57 - 58 - ```bash 59 - # Calendar events for today 60 - sol call activities list --source anticipated 61 - 62 - # Latest facet news 63 - sol call journal news "<facet>" 64 - ``` 65 - 66 - ### transcript — meeting transcripts 67 - 68 - Read what was said during meetings or any recorded time. 69 - 70 - ```bash 71 - # List transcript coverage ranges for a day 72 - sol call transcripts scan 73 - sol call transcripts scan 20260327 74 - 75 - # Read transcript content for a specific time range 76 - sol call transcripts read 77 - sol call transcripts read 20260327 78 - sol call transcripts read 20260327 --start 140000 --length 120 79 - 80 - # Transcript stats for a month 81 - sol call transcripts stats 82 - ``` 83 - 84 - `scan` first to see what's available, then `read` with `--start` (HHMMSS, 24h format) and `--length` (minutes) to narrow down. 85 - 86 - ### status — system health 87 - 88 - Check if solstone is running and how much data exists. 89 - 90 - ```bash 91 - # Journal storage summary (days, facets, size) 92 - sol call journal storage-summary 93 - 94 - # Journal-host diagnostics (read-only) 95 - sol call support diagnose 96 - ``` 97 - 98 - ## Paths 99 - 100 - `sol root` prints the solstone repo root — useful for scripting: `cd $(sol root)`, `SOL=$(sol root)`. 101 - 102 - ## Environment 103 - 104 - The `sol` CLI uses three environment variables that default sensibly: 105 - 106 - - `SOL_DAY` — defaults to today (`YYYYMMDD`) 107 - - `SOL_FACET` — defaults to all facets 108 - - `SOL_SEGMENT` — defaults to no segment 109 - 110 - External callers should never need to set these. The commands above use explicit flags (`--day`, `--facet`) when narrowing scope is needed. 111 - 112 - ## Composing queries 113 - 114 - For richer answers, combine multiple commands: 115 - 116 - **"Brief me on today"** — events + active relationships: 117 - ```bash 118 - sol call activities list --source anticipated 119 - sol call entities search --limit 5 120 - ``` 121 - 122 - **"Prep me for a meeting with X"** — recent transcript mentions: 123 - ```bash 124 - sol call journal search "<name>" --day-from 20260320 125 - ``` 126 - 127 - **"What did I miss yesterday?"** — yesterday's events + transcripts + news: 128 - ```bash 129 - sol call journal search "" -d 20260326 -a meetings 130 - sol call transcripts scan 20260326 131 - sol call journal news "<facet>" --day 20260326 132 - ``` 133 - 134 - ## Output format 135 - 136 - 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. 137 - 138 - ## What you cannot do 139 - 140 - This is a **read-only** interface. The journal is the person's private space. You cannot: 141 - 142 - - Create, delete, or modify facets 143 - - Attach or modify entities 144 - - Write news or observations 145 - - Run pipeline operations (think, indexer, transcribe) 146 - - Access internal agent state or orchestration 147 - 148 - If a task requires writing to the journal, it must be done from within the solstone project context using sol's internal skills. 149 - 150 - ## Error handling 151 - 152 - If `sol` is not found on PATH or returns an error: 153 - 154 - - **"command not found: sol"** — solstone is not installed. The user needs to run `journal setup` in their solstone project. 155 - - **"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. 156 - - **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. 157 - 158 - Do not retry failed commands. Report the error clearly so the user can investigate.
-92
solstone/talent/vit/SKILL.md
··· 1 - --- 2 - name: vit 3 - description: >- 4 - Ship solstone features as vit caps to the social capability network 5 - after a feature lands through hopper. Scoped to the publish/ship 6 - workflow that runs at the end of the VPE playbook; discovery, vetting, 7 - and consumption commands live in the user-wide `using-vit` skill. 8 - TRIGGER: ship a cap, publish solstone cap, vit ship, after hopper ship, 9 - final lode ship, capworthy feature, three-word ref, vit beacon, 10 - vit:github.com/solpbc/solstone-journal. 11 - --- 12 - 13 - # Vit Ship Skill 14 - 15 - Publish solstone capabilities as vit caps. Invoke via Bash: `vit ship ...` (other vit commands are covered by the `using-vit` skill). 16 - 17 - ## overview 18 - 19 - 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. 20 - 21 - **beacon:** `vit:github.com/solpbc/solstone-journal` 22 - 23 - **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. 24 - 25 - ## when to ship a cap 26 - 27 - 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. 28 - 29 - **ship when:** 30 - - the feature introduces a novel pattern or approach worth sharing 31 - - the feature is self-contained — someone could read the cap and understand the full approach 32 - - the feature would be useful to other agentic projects, journaling tools, or AI-native software 33 - 34 - **don't ship when:** 35 - - internal refactoring or code cleanup 36 - - bug fixes (unless the fix embodies a pattern worth documenting) 37 - - test-only or docs-only changes 38 - - individual lodes within a multi-lode feature (ship one cap for the whole feature at the end) 39 - 40 - ## how to ship 41 - 42 - ```bash 43 - vit ship --title "Feature Name" \ 44 - --description "One sentence explaining the value" \ 45 - --ref "three-word-ref" \ 46 - --kind feat <<'EOF' 47 - What this feature does, how it works, and the key architectural decisions. 48 - Written for another developer or agent who might adopt the approach in 49 - their own codebase. 50 - EOF 51 - ``` 52 - 53 - ### field guide 54 - 55 - - `--title` — concise noun phrase, 2-5 words (e.g., "Entity Intelligence Signals Index") 56 - - `--description` — one sentence explaining the value to someone who hasn't seen the code 57 - - `--ref` — three lowercase words separated by dashes, memorable slug for discovery (e.g., "entity-signal-indexing") 58 - - `--kind` — category: `feat`, `fix`, `test`, `docs`, `refactor`, `chore`, `perf`, `style` 59 - - `--recap <ref>` — only if this cap derives from another cap (e.g., after `vit remix`) 60 - - **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 61 - 62 - ### ref naming conventions 63 - 64 - the three-word ref should be descriptive and memorable: 65 - - good: `entity-signal-indexing`, `speaker-voice-attribution`, `routine-progressive-discovery` 66 - - bad: `update-fix-three`, `new-feature-impl`, `march-twentyseven-ship` 67 - 68 - ## pre-ship diagnostics 69 - 70 - ### `vit init` 71 - already done — beacon is set. run `vit init` to check current beacon status, or `vit init --beacon <url>` to change it. 72 - 73 - ### `vit doctor` 74 - read-only diagnostic. run to verify setup and beacon status before shipping when something looks off. 75 - 76 - For `vit skim`, `vit follow`, `vit learn`, `vit remix`, and other discovery/consumption commands, see the `using-vit` skill. 77 - 78 - ## troubleshooting 79 - 80 - | error | fix | 81 - |-------|-----| 82 - | `no DID configured` | tell the user to run `vit login <handle>` in their terminal | 83 - | `no beacon set` | run `vit init --beacon .` | 84 - | `session expired` | tell the user to run `vit login <handle>` | 85 - | invalid ref format | ref must be exactly three lowercase words separated by dashes | 86 - 87 - ## human-only commands 88 - 89 - these require browser interaction — tell the user to run them in their terminal: 90 - - `vit setup` — check prerequisites 91 - - `vit login <handle>` — authenticate via browser OAuth 92 - - `vit vet <ref>` — review a cap before trusting it
+84
solstone/think/command_polarity.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Shared command verb polarity helpers.""" 5 + 6 + from __future__ import annotations 7 + 8 + import re 9 + 10 + READ_VERBS: frozenset[str] = frozenset( 11 + { 12 + "load", 13 + "get", 14 + "read", 15 + "scan", 16 + "list", 17 + "show", 18 + "find", 19 + "match", 20 + "resolve", 21 + "query", 22 + "lookup", 23 + "status", 24 + "check", 25 + "validate", 26 + "discover", 27 + "format", 28 + "render", 29 + "extract", 30 + "parse", 31 + "view", 32 + "inspect", 33 + "info", 34 + "describe", 35 + "search", 36 + } 37 + ) 38 + 39 + WRITE_VERBS: frozenset[str] = frozenset( 40 + { 41 + "save", 42 + "create", 43 + "add", 44 + "insert", 45 + "append", 46 + "attach", 47 + "delete", 48 + "remove", 49 + "update", 50 + "rename", 51 + "move", 52 + "promote", 53 + "merge", 54 + "seed", 55 + "consolidate", 56 + "bootstrap", 57 + "backfill", 58 + "dispatch", 59 + "record", 60 + "ingest", 61 + "import", 62 + "rebuild", 63 + } 64 + ) 65 + 66 + 67 + def _verb_segments(verb: str) -> list[str]: 68 + base = verb.lstrip("_") 69 + return [part for part in re.split(r"[-_]+", base) if part] 70 + 71 + 72 + def is_read_verb(verb: str) -> bool: 73 + """Return True when any hyphen/underscore-split segment is a read verb.""" 74 + return any(part in READ_VERBS for part in _verb_segments(verb)) 75 + 76 + 77 + def classify_verb(verb: str) -> str: 78 + """Classify a CLI verb as read, write, or other by naming convention.""" 79 + segments = _verb_segments(verb) 80 + if any(part in READ_VERBS for part in segments): 81 + return "read" 82 + if any(part in WRITE_VERBS for part in segments): 83 + return "write" 84 + return "other"
+78 -1
solstone/think/doctor.py
··· 44 44 from typing import IO, Callable, Sequence 45 45 46 46 from solstone.think import features as _features 47 - from solstone.think import parakeet_readiness 47 + from solstone.think import parakeet_readiness, skills_cli 48 48 from solstone.think.health_cli import fetch_supervisor_status 49 49 from solstone.think.probe import ( 50 50 CONFIG_DIR_READABLE_CHECK, ··· 91 91 92 92 SOL_IMPORTABLE_CHECK = Check("sol_importable", "blocker", ("linux", "darwin")) 93 93 STALE_ALIAS_CHECK = Check("stale_alias_symlink", "blocker", ("linux", "darwin")) 94 + SKILL_STATE_CHECK = Check("skill_state", "advisory", ("linux", "darwin")) 94 95 JOURNAL_DIR_WRITABLE_CHECK = Check( 95 96 "journal_dir_writable", "blocker", ("linux", "darwin") 96 97 ) ··· 437 438 ) 438 439 439 440 441 + def _skill_state_problem_detail( 442 + skills_dir: Path, expected_sources: dict[str, Path] 443 + ) -> list[str]: 444 + problems: list[str] = [] 445 + expected_names = set(expected_sources) 446 + 447 + for name, source in sorted(expected_sources.items()): 448 + link = skills_dir / name 449 + if not link.is_symlink(): 450 + problems.append(f"missing router {name} at {link}") 451 + continue 452 + target_text = os.readlink(link) 453 + target_path = (skills_dir / target_text).resolve(strict=False) 454 + if target_path != source.resolve(strict=False): 455 + problems.append(f"foreign router {name} at {link} -> {target_text}") 456 + 457 + for link in sorted(skills_dir.iterdir()): 458 + if link.name in expected_names or not link.is_symlink(): 459 + continue 460 + problems.append(f"stale skill link {link.name} at {link}") 461 + 462 + return problems 463 + 464 + 465 + def skill_state_check(args: Args) -> CheckResult: 466 + del args 467 + check = SKILL_STATE_CHECK 468 + if is_packaged_install(): 469 + return make_result( 470 + check, 471 + "skip", 472 + "project skill links are a source-checkout concept", 473 + ) 474 + 475 + journal_text, _source = get_journal_info() 476 + journal_path = Path(journal_text) 477 + if not journal_path.exists(): 478 + return make_result(check, "skip", "no local journal") 479 + 480 + try: 481 + sources = skills_cli.discover_project_sources(ROOT) 482 + except Exception as exc: 483 + return make_result(check, "skip", f"project skill sources unavailable: {exc}") 484 + 485 + expected_sources = {source.name: source for source in sources} 486 + skill_dirs = [ 487 + journal_path / ".claude" / "skills", 488 + journal_path / ".agents" / "skills", 489 + ] 490 + existing_dirs = [path for path in skill_dirs if path.is_dir()] 491 + if not existing_dirs: 492 + return make_result(check, "skip", "no installed project skill dirs") 493 + 494 + problems: list[str] = [] 495 + for skills_dir in existing_dirs: 496 + problems.extend(_skill_state_problem_detail(skills_dir, expected_sources)) 497 + 498 + if not problems: 499 + names = ", ".join( 500 + name for name in skills_cli.ROUTER_SKILL_NAMES if name in expected_sources 501 + ) 502 + return make_result( 503 + check, 504 + "ok", 505 + f"router skills {names} are installed and current", 506 + ) 507 + 508 + fix = ( 509 + f"repair {', '.join(str(path) for path in existing_dirs)}: run `journal setup` " 510 + f"or `sol skills install --project {journal_path} --agent all`" 511 + ) 512 + return make_result(check, "warn", "; ".join(problems), fix) 513 + 514 + 440 515 def launchd_stale_plist_check(args: Args) -> CheckResult: 441 516 del args 442 517 check = LAUNCHD_STALE_PLIST_CHECK ··· 608 683 (SOL_IMPORTABLE_CHECK, sol_importable_check), 609 684 (LOCAL_BIN_SOL_REACHABLE_CHECK, local_bin_sol_reachable_check), 610 685 (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="sol")), 686 + (SKILL_STATE_CHECK, skill_state_check), 611 687 ] 612 688 613 689 JOURNAL_CHECKS: list[tuple[Check, Runner]] = [ ··· 621 697 (STALE_ALIAS_CHECK, partial(stale_alias_symlink_check, binary="journal")), 622 698 (LAUNCHD_STALE_PLIST_CHECK, launchd_stale_plist_check), 623 699 (DEFAULT_STT_READY_CHECK, default_stt_ready_check), 700 + (SKILL_STATE_CHECK, skill_state_check), 624 701 *FEATURE_CHECKS.values(), 625 702 ] 626 703
+4 -4
solstone/think/setup.py
··· 1162 1162 1163 1163 def skills_user_paths() -> list[Path]: 1164 1164 return [ 1165 - Path.home() / ".claude" / "skills" / "solstone" / "SKILL.md", 1166 - Path.home() / ".codex" / "skills" / "solstone" / "SKILL.md", 1167 - Path.home() / ".gemini" / "skills" / "solstone" / "SKILL.md", 1165 + Path.home() / ".claude" / "skills" / "sol" / "SKILL.md", 1166 + Path.home() / ".codex" / "skills" / "sol" / "SKILL.md", 1167 + Path.home() / ".gemini" / "skills" / "sol" / "SKILL.md", 1168 1168 ] 1169 1169 1170 1170 ··· 1636 1636 narrate(ctx, f" would run: {format_command(install_models_command(ctx))}") 1637 1637 narrate( 1638 1638 ctx, 1639 - f"[step 4/7] {_STEP_NAME[step_skills_user]} - installs solstone bundle for claude / codex / gemini", 1639 + f"[step 4/7] {_STEP_NAME[step_skills_user]} - installs the sol skill for claude / codex / gemini", 1640 1640 ) 1641 1641 if ctx.skip_skills: 1642 1642 narrate(ctx, " skipped: --skip-skills")
+272
solstone/think/skills_build.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Build generated command references for router skills.""" 5 + 6 + from __future__ import annotations 7 + 8 + import importlib 9 + import os 10 + import tempfile 11 + from dataclasses import dataclass 12 + from pathlib import Path 13 + 14 + import frontmatter 15 + import typer 16 + from typer.main import get_command 17 + 18 + from solstone.think.command_polarity import classify_verb 19 + 20 + ROOT = Path(__file__).resolve().parents[2] 21 + 22 + FRAGMENT_SOURCES: dict[str, Path] = { 23 + "activities": Path("solstone/apps/activities/talent/activities/SKILL.md"), 24 + "entities": Path("solstone/apps/entities/talent/entities/SKILL.md"), 25 + "health": Path("solstone/apps/health/talent/health/SKILL.md"), 26 + "speakers": Path("solstone/apps/speakers/talent/speakers/SKILL.md"), 27 + "support": Path("solstone/apps/support/talent/support/SKILL.md"), 28 + "transcripts": Path("solstone/apps/transcripts/talent/transcripts/SKILL.md"), 29 + } 30 + HEALTH_JOURNAL_NAMESPACES = ("journal health", "journal talent") 31 + JOURNAL_NAMESPACE_CONTRIBUTIONS = {"health": HEALTH_JOURNAL_NAMESPACES} 32 + 33 + SOL_COMMANDS_PATH = Path("solstone/talent/sol/references/commands.md") 34 + JOURNAL_COMMANDS_PATH = Path("solstone/talent/journal/references/commands.md") 35 + 36 + _BANNER = ( 37 + "<!-- GENERATED by sol skills build. " 38 + "Do not hand-edit; regenerate with make skills. -->" 39 + ) 40 + 41 + 42 + @dataclass(frozen=True) 43 + class Fragment: 44 + app: str 45 + path: Path 46 + repo_path: str 47 + description: str 48 + triggers: tuple[str, ...] 49 + 50 + 51 + def _absolute(path: Path) -> Path: 52 + return path if path.is_absolute() else ROOT / path 53 + 54 + 55 + def _repo_relative(path: Path) -> str: 56 + try: 57 + return path.relative_to(ROOT).as_posix() 58 + except ValueError: 59 + return path.as_posix() 60 + 61 + 62 + def _parse_triggers(description: str) -> tuple[str, ...]: 63 + marker = "TRIGGER:" 64 + if marker not in description: 65 + return () 66 + triggers: list[str] = [] 67 + for raw_token in description.split(marker, 1)[1].split(","): 68 + token = raw_token.strip() 69 + if token.lower().startswith("sol call"): 70 + continue 71 + token = token.removesuffix(".").strip() 72 + if token: 73 + triggers.append(token) 74 + return tuple(triggers) 75 + 76 + 77 + def _load_fragment(rel_path: Path) -> Fragment: 78 + path = _absolute(rel_path) 79 + try: 80 + post = frontmatter.load(path) 81 + except Exception as exc: 82 + raise ValueError(f"{path}: malformed frontmatter: {exc}") from exc 83 + 84 + name = post.metadata.get("name") 85 + if not name: 86 + raise ValueError(f"{path}: missing frontmatter name") 87 + if name != path.parent.name: 88 + raise ValueError( 89 + f"{path}: frontmatter name {name!r} does not match directory {path.parent.name!r}" 90 + ) 91 + description = post.metadata.get("description") 92 + if not description: 93 + raise ValueError(f"{path}: missing frontmatter description") 94 + if not isinstance(description, str): 95 + raise ValueError(f"{path}: frontmatter description must be a string") 96 + 97 + return Fragment( 98 + app=name, 99 + path=path, 100 + repo_path=_repo_relative(path), 101 + description=description, 102 + triggers=_parse_triggers(description), 103 + ) 104 + 105 + 106 + def _load_fragments() -> dict[str, Fragment]: 107 + fragments: dict[str, Fragment] = {} 108 + seen_names: dict[str, Path] = {} 109 + for app_key, rel_path in FRAGMENT_SOURCES.items(): 110 + fragment = _load_fragment(rel_path) 111 + previous = seen_names.get(fragment.app) 112 + if previous is not None: 113 + raise ValueError( 114 + f"{fragment.path}: duplicate app key {fragment.app!r}: " 115 + f"{previous} and {fragment.path}" 116 + ) 117 + if fragment.app != app_key: 118 + raise ValueError( 119 + f"{fragment.path}: fragment key {app_key!r} does not match name " 120 + f"{fragment.app!r}" 121 + ) 122 + seen_names[fragment.app] = fragment.path 123 + fragments[fragment.app] = fragment 124 + return fragments 125 + 126 + 127 + def _resolve_typer_app(app_name: str, source: Path) -> typer.Typer: 128 + module_name = ( 129 + "solstone.think.tools.health" 130 + if app_name == "health" 131 + else f"solstone.apps.{app_name}.call" 132 + ) 133 + try: 134 + module = importlib.import_module(module_name) 135 + except Exception as exc: 136 + raise ValueError(f"{source}: cannot import {module_name}: {exc}") from exc 137 + 138 + app = getattr(module, "app", None) 139 + if not isinstance(app, typer.Typer): 140 + raise ValueError(f"{source}: {module_name} does not export a Typer app") 141 + return app 142 + 143 + 144 + def _command_names(app_name: str, source: Path) -> tuple[str, ...]: 145 + app = _resolve_typer_app(app_name, source) 146 + try: 147 + command = get_command(app) 148 + names = tuple(sorted(command.commands.keys())) 149 + except Exception as exc: 150 + raise ValueError(f"{source}: cannot introspect Typer commands: {exc}") from exc 151 + if not names: 152 + raise ValueError(f"{source}: Typer app has no commands") 153 + return names 154 + 155 + 156 + def _format_list(values: tuple[str, ...]) -> str: 157 + return ", ".join(f"`{value}`" for value in values) 158 + 159 + 160 + def _polarity_groups(verbs: tuple[str, ...]) -> dict[str, tuple[str, ...]]: 161 + grouped = {"read": [], "write": [], "other": []} 162 + for verb in verbs: 163 + grouped[classify_verb(verb)].append(verb) 164 + return {group: tuple(sorted(values)) for group, values in grouped.items() if values} 165 + 166 + 167 + def _render_sol(fragments: dict[str, Fragment]) -> str: 168 + lines = [ 169 + _BANNER, 170 + "", 171 + "# Sol Router Commands", 172 + "", 173 + "Generated inventory of app command guidance for the `sol` router.", 174 + "", 175 + ] 176 + for app_name in sorted(fragments): 177 + fragment = fragments[app_name] 178 + verbs = _command_names(app_name, fragment.path) 179 + groups = _polarity_groups(verbs) 180 + lines.extend( 181 + [ 182 + f"## {app_name} — `sol call {app_name}`", 183 + "", 184 + ] 185 + ) 186 + if fragment.triggers: 187 + lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""]) 188 + for label, key in (("Read", "read"), ("Write", "write"), ("Other", "other")): 189 + values = groups.get(key) 190 + if values: 191 + lines.extend([f"{label}: {_format_list(values)}", ""]) 192 + lines.extend([f"Guidance: `{fragment.repo_path}`", ""]) 193 + return "\n".join(lines).rstrip() + "\n" 194 + 195 + 196 + def _render_journal(fragments: dict[str, Fragment]) -> str: 197 + lines = [ 198 + _BANNER, 199 + "", 200 + "# Journal Router Commands", 201 + "", 202 + "Generated app-contributed command guidance for the `journal` router.", 203 + "", 204 + ] 205 + for app_name in sorted(JOURNAL_NAMESPACE_CONTRIBUTIONS): 206 + fragment = fragments.get(app_name) 207 + if fragment is None: 208 + raise ValueError( 209 + f"{app_name}: journal namespace contribution has no fragment" 210 + ) 211 + namespaces = JOURNAL_NAMESPACE_CONTRIBUTIONS[app_name] 212 + formatted_namespaces = ", ".join(f"`{namespace}`" for namespace in namespaces) 213 + lines.extend( 214 + [ 215 + f"## {app_name} — {formatted_namespaces}", 216 + "", 217 + ] 218 + ) 219 + if fragment.triggers: 220 + lines.extend([f"Triggers: {_format_list(fragment.triggers)}", ""]) 221 + lines.extend([f"Guidance: `{fragment.repo_path}`", ""]) 222 + return "\n".join(lines).rstrip() + "\n" 223 + 224 + 225 + def render() -> dict[str, str]: 226 + """Render both generated reference files fully in memory.""" 227 + fragments = _load_fragments() 228 + return { 229 + str(_absolute(SOL_COMMANDS_PATH)): _render_sol(fragments), 230 + str(_absolute(JOURNAL_COMMANDS_PATH)): _render_journal(fragments), 231 + } 232 + 233 + 234 + def _atomic_write_text(path: Path, content: str) -> None: 235 + path.parent.mkdir(parents=True, exist_ok=True) 236 + fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=".tmp_", suffix=".tmp") 237 + try: 238 + with os.fdopen(fd, "w", encoding="utf-8") as out_file: 239 + out_file.write(content) 240 + os.replace(temp_path, path) 241 + except Exception: 242 + try: 243 + os.unlink(temp_path) 244 + except Exception: 245 + pass 246 + raise 247 + 248 + 249 + def build() -> list[Path]: 250 + """Write generated reference files atomically and return written paths.""" 251 + outputs = render() 252 + written: list[Path] = [] 253 + for raw_path, content in outputs.items(): 254 + path = Path(raw_path) 255 + _atomic_write_text(path, content) 256 + written.append(path) 257 + return written 258 + 259 + 260 + def check() -> list[Path]: 261 + """Return generated reference paths whose on-disk content is stale.""" 262 + stale: list[Path] = [] 263 + for raw_path, content in render().items(): 264 + path = Path(raw_path) 265 + try: 266 + current = path.read_text(encoding="utf-8") 267 + except FileNotFoundError: 268 + stale.append(path) 269 + continue 270 + if current != content: 271 + stale.append(path) 272 + return stale
+62 -19
solstone/think/skills_cli.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """sol skills — install, uninstall, and inspect coding-agent skills. 4 + """sol skills — build, install, uninstall, and inspect coding-agent skills. 5 5 6 6 Two install modes: 7 7 8 8 - User mode (default): copies the bundled umbrella skill 9 - solstone/talent/solstone/ into per-agent user config directories 9 + solstone/talent/sol/ into per-agent user config directories 10 10 (~/.claude/skills/, ~/.codex/skills/, ~/.gemini/skills/). 11 - - Project mode (--project [DIR]): symlinks every talent/ and apps/*/talent/ 12 - SKILL.md source into <DIR>/.claude/skills/ and <DIR>/.agents/skills/. 11 + - Project mode (--project [DIR]): symlinks the two router skills (sol, journal) 12 + into <DIR>/.claude/skills/ and <DIR>/.agents/skills/. 13 13 14 - Subcommands: install, uninstall, list. 14 + Subcommands: build, install, uninstall, list. 15 15 """ 16 16 17 17 from __future__ import annotations ··· 32 32 PROJECT_MULTI_AGENT = "agents" 33 33 PROJECT_CLAUDE_SKILLS_REL = ".claude/skills" 34 34 PROJECT_AGENTS_SKILLS_REL = ".agents/skills" 35 + ROUTER_SKILL_NAMES = ("sol", "journal") 35 36 GLOBAL_SKIP_MESSAGE = ( 36 37 "no AI coding agent config directories found — skipping skill registration" 37 38 ) 38 - SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/solstone/ in per-agent user config dirs. 39 - Project mode: symlinks/removes every talent/ and apps/*/talent/ skill under DIR. 39 + SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/sol/ in per-agent user config dirs. 40 + Project mode: symlinks/removes the two router skills (sol, journal) under DIR. 40 41 User-mode install creates missing agent config dirs and atomically replaces a 41 42 changed skill target.""" 42 43 ··· 116 117 117 118 def resolve_user_skill() -> Path: 118 119 """Return the bundled umbrella user skill source directory.""" 119 - skill_dir = Path(str(resources.files("solstone.talent") / "solstone")) 120 + skill_dir = Path(str(resources.files("solstone.talent") / "sol")) 120 121 skill_file = skill_dir / "SKILL.md" 121 122 if not skill_file.is_file(): 122 123 raise FileNotFoundError( 123 124 "expected bundled umbrella skill at " 124 - f"solstone/talent/solstone/SKILL.md ({skill_file})" 125 + f"solstone/talent/sol/SKILL.md ({skill_file})" 125 126 ) 126 127 return skill_dir 127 128 128 129 129 130 def discover_project_sources(repo_root: Path) -> list[Path]: 130 131 """Return project skill source directories, rejecting duplicate names.""" 131 - package_root = repo_root / "solstone" 132 - sources = sorted( 133 - [path.parent for path in (package_root / "talent").glob("*/SKILL.md")] 134 - + [path.parent for path in (package_root / "apps").glob("*/talent/*/SKILL.md")] 135 - ) 132 + sources: list[Path] = [] 133 + for name in ROUTER_SKILL_NAMES: 134 + source = repo_root / "solstone" / "talent" / name 135 + skill_file = source / "SKILL.md" 136 + if not skill_file.is_file(): 137 + raise FileNotFoundError(f"expected project skill at {skill_file}") 138 + sources.append(source) 139 + sources = sorted(sources) 136 140 seen: dict[str, Path] = {} 137 141 for source in sources: 138 142 previous = seen.get(source.name) ··· 366 370 if not link_parent.is_dir(): 367 371 return 368 372 for link in sorted(link_parent.iterdir()): 369 - if not link.is_symlink() or link.name in source_names: 373 + if link.name in source_names: 374 + continue 375 + if not link.is_symlink(): 376 + rows.append( 377 + ActionRow( 378 + agent, 379 + link.name, 380 + "warning", 381 + link, 382 + reason="user content at stale target preserved", 383 + ) 384 + ) 370 385 continue 371 386 try: 372 387 link.unlink() ··· 524 539 prog="sol skills", 525 540 description=( 526 541 "Install, uninstall, and inspect coding-agent skills. " 527 - "User mode copies the bundled umbrella skill solstone/talent/solstone/ " 528 - "into per-agent user config dirs. Project mode symlinks every talent/ " 529 - "and apps/*/talent/ SKILL.md source into the selected project directory. " 542 + "User mode copies the bundled umbrella skill solstone/talent/sol/ " 543 + "into per-agent user config dirs. Project mode symlinks the two " 544 + "router skills (sol, journal) into the selected project directory. " 530 545 "User-mode install creates missing agent config dirs and atomically " 531 546 "replaces a changed skill target." 532 547 ), ··· 554 569 list_parser = subparsers.add_parser("list", help="list skill install status") 555 570 _add_project_option(list_parser) 556 571 572 + build_parser = subparsers.add_parser( 573 + "build", help="build generated router skill references" 574 + ) 575 + build_parser.add_argument( 576 + "--check", 577 + action="store_true", 578 + help="verify generated router skill references are current without writing", 579 + ) 580 + 557 581 return parser 558 582 559 583 ··· 579 603 parser = _build_parser() 580 604 args = parser.parse_args() 581 605 repo_root = Path(get_project_root()) 582 - target = _resolve_project_target(args.project) 606 + target = _resolve_project_target(getattr(args, "project", None)) 583 607 584 608 try: 609 + if args.cmd == "build": 610 + from solstone.think import skills_build 611 + 612 + if args.check: 613 + stale = skills_build.check() 614 + for path in stale: 615 + print( 616 + f"stale generated reference {path} (run `sol skills build`)", 617 + file=sys.stderr, 618 + ) 619 + if stale: 620 + return 1 621 + print("generated skill references are current") 622 + return 0 623 + 624 + for path in skills_build.build(): 625 + print(f"generated {path}") 626 + return 0 627 + 585 628 if args.cmd == "install": 586 629 if target is None: 587 630 skill_dir = resolve_user_skill()
-60
solstone/think/start.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 - import logging 9 - import sys 10 - from pathlib import Path 11 - 12 - import solstone 13 - from solstone.think.app_supervised import is_app_supervised 14 - from solstone.think.install_guard import alias_paths, install_wrappers 15 - from solstone.think.journal_io import atomic_replace 16 - from solstone.think.service import reconcile_installed_unit 17 - from solstone.think.skills_cli import install_project 18 - from solstone.think.user_config import config_path 19 - from solstone.think.utils import get_journal, get_project_root 20 - 21 - logger = logging.getLogger(__name__) 22 - 23 - 24 - def _version_marker_path() -> Path: 25 - return config_path().parent / ".last-start-version" 26 - 27 - 28 - def _version_marker_is_current(path: Path) -> bool: 29 - try: 30 - return path.read_text(encoding="utf-8") == f"{solstone.__version__}\n" 31 - except FileNotFoundError: 32 - return False 33 - 34 - 35 - def _install_current_wrappers() -> None: 36 - bin_dir = Path(sys.executable).parent 37 - paths = alias_paths() 38 - sol_bins = {binary: str(bin_dir / binary) for binary in paths} 39 - install_wrappers(get_journal(), sol_bins, paths=paths) 40 - 41 - 42 - def _refresh_skill_links() -> None: 43 - report = install_project(Path(get_project_root()), Path(get_journal()), ["all"]) 44 - if report.error_count: 45 - raise RuntimeError(f"skill refresh failed with {report.error_count} error(s)") 46 - 47 - 48 - def _refresh_for_version_marker(skip_reconcile: bool = False) -> None: 49 - marker_path = _version_marker_path() 50 - if _version_marker_is_current(marker_path): 51 - return 52 - 53 - _install_current_wrappers() 54 - if not skip_reconcile: 55 - reconcile_installed_unit() 56 - _refresh_skill_links() 57 - atomic_replace(marker_path, f"{solstone.__version__}\n") 58 - 59 8 60 9 def main() -> None: 61 - app_supervised = is_app_supervised(sys.argv) 62 - try: 63 - if not app_supervised: 64 - reconcile_installed_unit() 65 - _refresh_for_version_marker(skip_reconcile=app_supervised) 66 - except Exception: 67 - logger.exception("journal start failed during service reconciliation") 68 - sys.exit(1) 69 - 70 10 from solstone.think import supervisor 71 11 72 12 supervisor.main()
+3
tests/test_doctor.py
··· 364 364 "sol_importable", 365 365 "local_bin_sol_reachable", 366 366 "stale_alias_symlink", 367 + "skill_state", 367 368 } 368 369 369 370 ··· 877 878 "sol_importable", 878 879 "local_bin_sol_reachable", 879 880 "stale_alias_symlink", 881 + "skill_state", 880 882 } 881 883 882 884 ··· 907 909 "sol_importable", 908 910 "local_bin_sol_reachable", 909 911 "stale_alias_symlink", 912 + "skill_state", 910 913 } 911 914 assert not any( 912 915 name.startswith("service_")
+78
tests/test_journal_doctor.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 + import os 6 7 import plistlib 7 8 from pathlib import Path 8 9 from types import SimpleNamespace ··· 60 61 "import_install_guard", 61 62 lambda: (install_guard.AliasState, install_guard.check_alias), 62 63 ) 64 + 65 + 66 + def tree_snapshot(root: Path) -> list[tuple[str, str, str]]: 67 + snapshot: list[tuple[str, str, str]] = [] 68 + for path in sorted(root.rglob("*")): 69 + rel = path.relative_to(root).as_posix() 70 + if path.is_symlink(): 71 + snapshot.append((rel, "symlink", os.readlink(path))) 72 + elif path.is_file(): 73 + snapshot.append((rel, "file", path.read_text(encoding="utf-8"))) 74 + elif path.is_dir(): 75 + snapshot.append((rel, "dir", "")) 76 + return snapshot 77 + 78 + 79 + def install_router_skill_links(doctor, journal: Path) -> None: 80 + sources = doctor.skills_cli.discover_project_sources(doctor.ROOT) 81 + for rel_dir in [Path(".claude/skills"), Path(".agents/skills")]: 82 + skills_dir = journal / rel_dir 83 + skills_dir.mkdir(parents=True) 84 + for source in sources: 85 + link = skills_dir / source.name 86 + link.symlink_to(os.path.relpath(source, skills_dir)) 63 87 64 88 65 89 def test_service_running_ok(doctor, monkeypatch): ··· 202 226 assert by_name["journal_sync"].status == "skip" 203 227 assert by_name["service_identity"].status == "skip" 204 228 assert by_name["service_running"].status == "skip" 229 + assert by_name["skill_state"].status == "skip" 205 230 assert by_name["disk_space"].status in {"ok", "warn"} 206 231 assert by_name["config_dir_readable"].status == "ok" 207 232 assert by_name["feature:pdf"].status in {"ok", "warn"} 208 233 assert by_name["feature:whisper"].status in {"ok", "warn"} 234 + 235 + 236 + def test_skill_state_no_local_journal_skips(doctor, monkeypatch, tmp_path): 237 + journal = tmp_path / "missing-journal" 238 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 239 + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) 240 + 241 + result = doctor.skill_state_check(args(doctor)) 242 + 243 + assert result.status == "skip" 244 + assert result.detail == "no local journal" 245 + 246 + 247 + def test_skill_state_current_router_links_ok(doctor, monkeypatch, tmp_path): 248 + journal = tmp_path / "journal" 249 + journal.mkdir() 250 + install_router_skill_links(doctor, journal) 251 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 252 + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) 253 + 254 + result = doctor.skill_state_check(args(doctor)) 255 + 256 + assert result.status == "ok" 257 + assert result.detail == "router skills sol, journal are installed and current" 258 + 259 + 260 + def test_skill_state_warns_for_stale_and_missing_links_without_writing( 261 + doctor, monkeypatch, tmp_path 262 + ): 263 + journal = tmp_path / "journal" 264 + skills_dir = journal / ".claude" / "skills" 265 + skills_dir.mkdir(parents=True) 266 + sources = { 267 + source.name: source 268 + for source in doctor.skills_cli.discover_project_sources(doctor.ROOT) 269 + } 270 + (skills_dir / "journal").symlink_to(os.path.relpath(sources["journal"], skills_dir)) 271 + (skills_dir / "entities").symlink_to( 272 + "../../../solstone/apps/entities/talent/entities" 273 + ) 274 + before = tree_snapshot(journal) 275 + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) 276 + monkeypatch.setattr(doctor, "is_packaged_install", lambda: False) 277 + 278 + result = doctor.skill_state_check(args(doctor)) 279 + 280 + assert result.status == "warn" 281 + assert f"missing router sol at {skills_dir / 'sol'}" in result.detail 282 + assert f"stale skill link entities at {skills_dir / 'entities'}" in result.detail 283 + assert result.fix is not None 284 + assert "journal setup" in result.fix 285 + assert f"sol skills install --project {journal} --agent all" in result.fix 286 + assert tree_snapshot(journal) == before 209 287 210 288 211 289 class TestJournalAlias:
+1
tests/test_journal_skill.py
··· 48 48 "references/captures.md", 49 49 "references/logs.md", 50 50 "references/storage.md", 51 + "references/commands.md", 51 52 ] 52 53 53 54 for rel_path in references:
+8 -188
tests/test_journal_start.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 - import sys 7 - from pathlib import Path 8 6 from unittest.mock import MagicMock 9 - 10 - import pytest 11 7 12 8 from solstone.think import start 13 - from solstone.think.app_supervised import FLAG, SELECTOR_ENV 14 - from solstone.think.service import Reconciled 15 - 16 - 17 - def _patch_marker(monkeypatch: pytest.MonkeyPatch, marker: Path) -> None: 18 - monkeypatch.setattr(start, "_version_marker_path", lambda: marker) 19 - 20 - 21 - def test_start_reconcile_idempotent_no_rewrite(monkeypatch, tmp_path): 22 - marker = tmp_path / ".last-start-version" 23 - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") 24 - _patch_marker(monkeypatch, marker) 25 - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) 26 - supervisor = MagicMock() 27 - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) 28 - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) 29 - 30 - start.main() 31 - 32 - reconcile.assert_called_once_with() 33 - supervisor.assert_called_once_with() 34 9 35 10 36 - def test_start_version_marker_mismatch_triggers_refresh(monkeypatch, tmp_path): 37 - marker = tmp_path / ".last-start-version" 38 - marker.write_text("old-version\n", encoding="utf-8") 39 - _patch_marker(monkeypatch, marker) 40 - calls: list[str] = [] 41 - monkeypatch.setattr( 42 - start, "_install_current_wrappers", lambda: calls.append("wrappers") 43 - ) 44 - monkeypatch.setattr( 45 - start, 46 - "reconcile_installed_unit", 47 - lambda: calls.append("reconcile") or Reconciled(False, None, None, None), 48 - ) 49 - monkeypatch.setattr(start, "_refresh_skill_links", lambda: calls.append("skills")) 50 - 51 - start._refresh_for_version_marker() 52 - 53 - assert calls == ["wrappers", "reconcile", "skills"] 54 - assert marker.read_text(encoding="utf-8") == f"{start.solstone.__version__}\n" 55 - 56 - 57 - @pytest.mark.parametrize("selector", ["flag", "env"]) 58 - def test_app_supervised_start_skips_reconcile_but_refreshes_version_marker_artifacts( 59 - selector, monkeypatch, tmp_path 60 - ): 61 - marker = tmp_path / ".last-start-version" 62 - marker.write_text("old-version\n", encoding="utf-8") 63 - _patch_marker(monkeypatch, marker) 64 - monkeypatch.delenv(SELECTOR_ENV, raising=False) 65 - argv = ["journal", "start"] 66 - if selector == "flag": 67 - argv.append(FLAG) 68 - else: 69 - monkeypatch.setenv(SELECTOR_ENV, "1") 70 - monkeypatch.setattr(sys, "argv", argv) 71 - 72 - calls: list[str] = [] 73 - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) 74 - supervisor = MagicMock() 75 - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) 76 - monkeypatch.setattr( 77 - start, "_install_current_wrappers", lambda: calls.append("wrappers") 78 - ) 79 - monkeypatch.setattr(start, "_refresh_skill_links", lambda: calls.append("skills")) 80 - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) 81 - 82 - start.main() 83 - 84 - reconcile.assert_not_called() 85 - assert calls == ["wrappers", "skills"] 86 - assert marker.read_text(encoding="utf-8") == f"{start.solstone.__version__}\n" 87 - supervisor.assert_called_once_with() 88 - 89 - 90 - def test_default_start_reconciles_both_sites_with_stale_marker(monkeypatch, tmp_path): 91 - marker = tmp_path / ".last-start-version" 92 - marker.write_text("old-version\n", encoding="utf-8") 93 - _patch_marker(monkeypatch, marker) 94 - monkeypatch.delenv(SELECTOR_ENV, raising=False) 95 - monkeypatch.setattr(sys, "argv", ["journal", "start"]) 96 - 97 - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) 98 - supervisor = MagicMock() 99 - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) 100 - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) 101 - monkeypatch.setattr(start, "_refresh_skill_links", lambda: None) 102 - monkeypatch.setattr("solstone.think.supervisor.main", supervisor) 103 - 104 - start.main() 105 - 106 - assert reconcile.call_count == 2 107 - supervisor.assert_called_once_with() 108 - 109 - 110 - @pytest.mark.skipif(sys.platform != "linux", reason="linux reconcile regression") 111 - def test_default_start_reconciles_both_sites_with_stale_marker_on_linux( 112 - monkeypatch, tmp_path 113 - ): 114 - marker = tmp_path / ".last-start-version" 115 - marker.write_text("old-version\n", encoding="utf-8") 116 - _patch_marker(monkeypatch, marker) 117 - monkeypatch.delenv(SELECTOR_ENV, raising=False) 118 - monkeypatch.setattr(sys, "argv", ["journal", "start"]) 119 - 120 - reconcile = MagicMock(return_value=Reconciled(False, None, None, None)) 121 - monkeypatch.setattr(start, "reconcile_installed_unit", reconcile) 122 - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) 123 - monkeypatch.setattr(start, "_refresh_skill_links", lambda: None) 124 - monkeypatch.setattr("solstone.think.supervisor.main", MagicMock()) 125 - 126 - start.main() 127 - 128 - assert reconcile.call_count == 2 129 - 130 - 131 - def test_start_version_marker_match_is_noop(monkeypatch, tmp_path): 132 - marker = tmp_path / ".last-start-version" 133 - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") 134 - _patch_marker(monkeypatch, marker) 135 - monkeypatch.setattr( 136 - start, 137 - "_install_current_wrappers", 138 - lambda: pytest.fail("wrappers should not refresh"), 139 - ) 140 - monkeypatch.setattr( 141 - start, 142 - "reconcile_installed_unit", 143 - lambda: pytest.fail("reconcile should not refresh"), 144 - ) 145 - monkeypatch.setattr( 146 - start, 147 - "_refresh_skill_links", 148 - lambda: pytest.fail("skills should not refresh"), 149 - ) 150 - 151 - start._refresh_for_version_marker() 152 - 153 - 154 - def test_start_invokes_supervisor(monkeypatch, tmp_path): 155 - marker = tmp_path / ".last-start-version" 156 - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") 157 - _patch_marker(monkeypatch, marker) 158 - monkeypatch.setattr( 159 - start, 160 - "reconcile_installed_unit", 161 - lambda: Reconciled(False, None, None, None), 162 - ) 11 + def test_start_invokes_supervisor(monkeypatch): 163 12 supervisor = MagicMock() 164 13 monkeypatch.setattr("solstone.think.supervisor.main", supervisor) 165 14 ··· 168 17 supervisor.assert_called_once_with() 169 18 170 19 171 - def test_start_reconcile_failure_exits_nonzero(monkeypatch, tmp_path): 172 - marker = tmp_path / ".last-start-version" 173 - marker.write_text(f"{start.solstone.__version__}\n", encoding="utf-8") 174 - _patch_marker(monkeypatch, marker) 175 - monkeypatch.setattr( 176 - start, 177 - "reconcile_installed_unit", 178 - MagicMock(side_effect=OSError("boom")), 179 - ) 20 + def test_start_exports_only_main_callable(): 21 + callables = { 22 + name 23 + for name, value in vars(start).items() 24 + if callable(value) and not name.startswith("__") 25 + } 180 26 181 - with pytest.raises(SystemExit) as exc_info: 182 - start.main() 183 - 184 - assert exc_info.value.code == 1 185 - 186 - 187 - def test_start_skill_refresh_error_exits_nonzero(monkeypatch, tmp_path): 188 - marker = tmp_path / ".last-start-version" 189 - marker.write_text("old-version\n", encoding="utf-8") 190 - _patch_marker(monkeypatch, marker) 191 - monkeypatch.setattr( 192 - start, 193 - "reconcile_installed_unit", 194 - lambda: Reconciled(False, None, None, None), 195 - ) 196 - monkeypatch.setattr(start, "_install_current_wrappers", lambda: None) 197 - monkeypatch.setattr( 198 - start, 199 - "_refresh_skill_links", 200 - MagicMock(side_effect=RuntimeError("skill refresh failed")), 201 - ) 202 - 203 - with pytest.raises(SystemExit) as exc_info: 204 - start.main() 205 - 206 - assert exc_info.value.code == 1 207 - assert marker.read_text(encoding="utf-8") == "old-version\n" 27 + assert callables == {"main"}
+4 -4
tests/test_setup.py
··· 353 353 "journal": [setup.config_path(), journal], 354 354 "install_models": setup.model_paths(), 355 355 "skills_user": [ 356 - Path.home() / ".claude" / "skills" / "solstone" / "SKILL.md", 357 - Path.home() / ".codex" / "skills" / "solstone" / "SKILL.md", 358 - Path.home() / ".gemini" / "skills" / "solstone" / "SKILL.md", 356 + Path.home() / ".claude" / "skills" / "sol" / "SKILL.md", 357 + Path.home() / ".codex" / "skills" / "sol" / "SKILL.md", 358 + Path.home() / ".gemini" / "skills" / "sol" / "SKILL.md", 359 359 ], 360 360 "skills_journal": [ 361 361 journal / ".claude" / "skills", ··· 1702 1702 assert state is install_guard.AliasState.OWNED 1703 1703 1704 1704 1705 - def test_step_skills_user_installs_solstone_bundle_for_all_agents( 1705 + def test_step_skills_user_installs_sol_skill_for_all_agents( 1706 1706 tmp_path: Path, 1707 1707 monkeypatch: pytest.MonkeyPatch, 1708 1708 ) -> None:
+166
tests/test_skills_build.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import shutil 7 + from pathlib import Path 8 + 9 + import frontmatter 10 + import pytest 11 + 12 + from solstone.think import skills_build 13 + from solstone.think.command_polarity import classify_verb 14 + 15 + REAL_ROOT = Path(__file__).resolve().parents[1] 16 + 17 + 18 + def _copy_fragment_tree(tmp_path: Path) -> Path: 19 + root = tmp_path / "repo" 20 + for rel_path in skills_build.FRAGMENT_SOURCES.values(): 21 + src = REAL_ROOT / rel_path 22 + dst = root / rel_path 23 + dst.parent.mkdir(parents=True, exist_ok=True) 24 + shutil.copy2(src, dst) 25 + return root 26 + 27 + 28 + def _patch_root(monkeypatch: pytest.MonkeyPatch, root: Path) -> None: 29 + monkeypatch.setattr(skills_build, "ROOT", root) 30 + 31 + 32 + def _output_paths(root: Path) -> set[Path]: 33 + return { 34 + root / skills_build.SOL_COMMANDS_PATH, 35 + root / skills_build.JOURNAL_COMMANDS_PATH, 36 + } 37 + 38 + 39 + def _section(content: str, heading: str) -> str: 40 + start = content.index(heading) 41 + next_heading = content.find("\n## ", start + 1) 42 + if next_heading == -1: 43 + return content[start:] 44 + return content[start:next_heading] 45 + 46 + 47 + def test_render_is_deterministic_and_build_check_is_current(monkeypatch, tmp_path): 48 + root = _copy_fragment_tree(tmp_path) 49 + _patch_root(monkeypatch, root) 50 + 51 + first = skills_build.render() 52 + second = skills_build.render() 53 + assert first == second 54 + 55 + written = skills_build.build() 56 + assert set(written) == _output_paths(root) 57 + assert skills_build.check() == [] 58 + 59 + before = {path: path.read_bytes() for path in written} 60 + skills_build.build() 61 + after = {path: path.read_bytes() for path in written} 62 + assert before == after 63 + 64 + 65 + def test_check_detects_staleness_without_writing(monkeypatch, tmp_path): 66 + root = _copy_fragment_tree(tmp_path) 67 + _patch_root(monkeypatch, root) 68 + written = skills_build.build() 69 + stale_path = written[0] 70 + stale_path.write_text( 71 + stale_path.read_text(encoding="utf-8") + "stale\n", encoding="utf-8" 72 + ) 73 + before = stale_path.read_text(encoding="utf-8") 74 + 75 + stale = skills_build.check() 76 + 77 + assert stale == [stale_path] 78 + assert stale_path.read_text(encoding="utf-8") == before 79 + 80 + 81 + @pytest.mark.parametrize( 82 + ("content", "message"), 83 + [ 84 + ("---\nname: [\n---\n", "malformed frontmatter"), 85 + ("---\nname: wrong\ndescription: nope\n---\n", "does not match directory"), 86 + ("---\nname: activities\n---\n", "missing frontmatter description"), 87 + ], 88 + ) 89 + def test_malformed_fragment_raises_with_path_without_partial_output( 90 + monkeypatch, tmp_path, content, message 91 + ): 92 + root = _copy_fragment_tree(tmp_path) 93 + _patch_root(monkeypatch, root) 94 + target = root / skills_build.FRAGMENT_SOURCES["activities"] 95 + target.write_text(content, encoding="utf-8") 96 + 97 + with pytest.raises(ValueError) as exc_info: 98 + skills_build.build() 99 + 100 + assert str(target) in str(exc_info.value) 101 + assert message in str(exc_info.value) 102 + for output in _output_paths(root): 103 + assert not output.exists() 104 + 105 + 106 + def test_duplicate_fragment_name_raises_with_path_without_partial_output( 107 + monkeypatch, tmp_path 108 + ): 109 + root = _copy_fragment_tree(tmp_path) 110 + _patch_root(monkeypatch, root) 111 + duplicate = Path("solstone/apps/entities-copy/talent/entities/SKILL.md") 112 + duplicate_path = root / duplicate 113 + duplicate_path.parent.mkdir(parents=True, exist_ok=True) 114 + shutil.copy2(root / skills_build.FRAGMENT_SOURCES["entities"], duplicate_path) 115 + sources = dict(skills_build.FRAGMENT_SOURCES) 116 + sources["entities_copy"] = duplicate 117 + monkeypatch.setattr(skills_build, "FRAGMENT_SOURCES", sources) 118 + 119 + with pytest.raises(ValueError) as exc_info: 120 + skills_build.build() 121 + 122 + assert str(duplicate_path) in str(exc_info.value) 123 + assert "duplicate app key 'entities'" in str(exc_info.value) 124 + for output in _output_paths(root): 125 + assert not output.exists() 126 + 127 + 128 + def test_polarity_classification_and_rendered_other_group(monkeypatch, tmp_path): 129 + assert classify_verb("search") == "read" 130 + assert classify_verb("merge") == "write" 131 + assert classify_verb("detect") == "other" 132 + assert classify_verb("scan") == "read" 133 + assert classify_verb("segments") == "other" 134 + 135 + root = _copy_fragment_tree(tmp_path) 136 + _patch_root(monkeypatch, root) 137 + content = skills_build.render()[str(root / skills_build.SOL_COMMANDS_PATH)] 138 + health = _section(content, "## health") 139 + assert "Other: `for-range`, `full`, `pipeline`, `summary`" in health 140 + 141 + 142 + def test_health_contributes_to_both_router_references(monkeypatch, tmp_path): 143 + root = _copy_fragment_tree(tmp_path) 144 + _patch_root(monkeypatch, root) 145 + 146 + outputs = skills_build.render() 147 + sol = outputs[str(root / skills_build.SOL_COMMANDS_PATH)] 148 + journal = outputs[str(root / skills_build.JOURNAL_COMMANDS_PATH)] 149 + 150 + assert "## health — `sol call health`" in sol 151 + assert "## health — `journal health`, `journal talent`" in journal 152 + assert "Guidance: `solstone/apps/health/talent/health/SKILL.md`" in journal 153 + 154 + 155 + def test_trigger_parsing_matches_fragment_description(): 156 + post = frontmatter.load(REAL_ROOT / skills_build.FRAGMENT_SOURCES["activities"]) 157 + 158 + assert skills_build._parse_triggers(post.metadata["description"]) == ( 159 + "activity", 160 + "activities", 161 + "work session", 162 + "completed span", 163 + "mute/unmute", 164 + "activity record", 165 + "meeting attendees", 166 + )
+116 -50
tests/test_skills_cli.py
··· 4 4 from __future__ import annotations 5 5 6 6 import os 7 - import shutil 8 7 import sys 9 8 from pathlib import Path 10 9 11 10 import pytest 12 11 13 - from solstone.think import skills_cli 12 + from solstone.think import skills_build, skills_cli 14 13 from solstone.think.skills_cli import ( 15 14 install_project, 16 15 install_user, ··· 27 26 28 27 29 28 def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: 30 - skill_dir = tmp_path / "solstone" 29 + skill_dir = tmp_path / "sol" 31 30 _write_skill(skill_dir, content) 32 31 return skill_dir 33 32 34 33 35 34 def _mini_project_repo(tmp_path: Path) -> Path: 36 35 repo = tmp_path / "repo" 36 + _write_skill(repo / "solstone" / "talent" / "sol") 37 37 _write_skill(repo / "solstone" / "talent" / "journal") 38 38 _write_skill(repo / "solstone" / "talent" / "routines") 39 39 _write_skill(repo / "solstone" / "apps" / "foo" / "talent" / "bar") ··· 57 57 assert report.error_count == 0 58 58 source = repo / "SKILL.md" 59 59 assert ( 60 - home / ".claude" / "skills" / "solstone" / "SKILL.md" 60 + home / ".claude" / "skills" / "sol" / "SKILL.md" 61 61 ).read_bytes() == source.read_bytes() 62 62 assert ( 63 - home / ".codex" / "skills" / "solstone" / "SKILL.md" 63 + home / ".codex" / "skills" / "sol" / "SKILL.md" 64 64 ).read_bytes() == source.read_bytes() 65 - assert {path.name for path in (home / ".claude" / "skills").iterdir()} == { 66 - "solstone" 67 - } 68 - assert {path.name for path in (home / ".codex" / "skills").iterdir()} == { 69 - "solstone" 70 - } 65 + assert {path.name for path in (home / ".claude" / "skills").iterdir()} == {"sol"} 66 + assert {path.name for path in (home / ".codex" / "skills").iterdir()} == {"sol"} 71 67 72 68 73 69 def test_install_user_creates_missing_codex_parent_dir(tmp_path): ··· 77 73 report = install_user(repo, home, ["codex"]) 78 74 79 75 assert report.error_count == 0 80 - assert (home / ".codex" / "skills" / "solstone" / "SKILL.md").exists() 76 + assert (home / ".codex" / "skills" / "sol" / "SKILL.md").exists() 81 77 assert report.rows == [ 82 78 skills_cli.ActionRow( 83 79 "codex", 84 - "solstone", 80 + "sol", 85 81 "installed", 86 - home / ".codex" / "skills" / "solstone", 82 + home / ".codex" / "skills" / "sol", 87 83 ) 88 84 ] 89 85 ··· 95 91 report = install_user(repo, home, ["gemini"]) 96 92 97 93 assert report.error_count == 0 98 - assert (home / ".gemini" / "skills" / "solstone" / "SKILL.md").exists() 94 + assert (home / ".gemini" / "skills" / "sol" / "SKILL.md").exists() 99 95 assert report.rows == [ 100 96 skills_cli.ActionRow( 101 97 "gemini", 102 - "solstone", 98 + "sol", 103 99 "installed", 104 - home / ".gemini" / "skills" / "solstone", 100 + home / ".gemini" / "skills" / "sol", 105 101 ) 106 102 ] 107 103 ··· 114 110 115 111 assert report.error_count == 0 116 112 for agent in [".claude", ".codex", ".gemini"]: 117 - assert (home / agent / "skills" / "solstone" / "SKILL.md").exists() 113 + assert (home / agent / "skills" / "sol" / "SKILL.md").exists() 118 114 assert [row.action for row in report.rows] == [ 119 115 "installed", 120 116 "installed", ··· 131 127 report = install_user(repo, home, ["claude"]) 132 128 133 129 assert report.error_count == 0 134 - assert ( 135 - home / ".claude" / "skills" / "solstone" / "SKILL.md" 136 - ).read_bytes() == b"second" 130 + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").read_bytes() == b"second" 137 131 138 132 139 133 def test_install_user_replaces_existing_regular_file_target(tmp_path): 140 134 repo = _mini_user_repo(tmp_path, b"fresh") 141 135 home = _home(tmp_path, ".claude") 142 - target = home / ".claude" / "skills" / "solstone" 136 + target = home / ".claude" / "skills" / "sol" 143 137 target.mkdir(parents=True) 144 138 (target / "SKILL.md").write_bytes(b"stale") 145 139 ··· 152 146 def test_install_user_replaces_stray_symlink_target(tmp_path): 153 147 repo = _mini_user_repo(tmp_path) 154 148 home = _home(tmp_path, ".claude") 155 - target = home / ".claude" / "skills" / "solstone" 149 + target = home / ".claude" / "skills" / "sol" 156 150 target.parent.mkdir(parents=True) 157 151 target.symlink_to(tmp_path / "whatever") 158 152 ··· 167 161 def test_install_user_replaces_stray_regular_file_target(tmp_path): 168 162 repo = _mini_user_repo(tmp_path) 169 163 home = _home(tmp_path, ".claude") 170 - target = home / ".claude" / "skills" / "solstone" 164 + target = home / ".claude" / "skills" / "sol" 171 165 target.parent.mkdir(parents=True) 172 166 target.write_text("not a dir", encoding="utf-8") 173 167 ··· 182 176 def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): 183 177 repo = _mini_user_repo(tmp_path) 184 178 home = _home(tmp_path, ".claude") 185 - target = home / ".claude" / "skills" / "solstone" 179 + target = home / ".claude" / "skills" / "sol" 186 180 target.mkdir(parents=True) 187 181 target.chmod(0o500) 188 182 try: ··· 200 194 def test_uninstall_user_removes_only_bundle_dirs(tmp_path): 201 195 repo = _mini_user_repo(tmp_path) 202 196 home = _home(tmp_path, ".claude") 203 - solstone = home / ".claude" / "skills" / "solstone" 197 + sol = home / ".claude" / "skills" / "sol" 204 198 hop = home / ".claude" / "skills" / "hop" 205 - _write_skill(solstone) 199 + _write_skill(sol) 206 200 _write_skill(hop) 207 201 208 202 report = uninstall_user(repo, home, ["claude"]) 209 203 210 204 assert report.error_count == 0 211 - assert not solstone.exists() 205 + assert not sol.exists() 212 206 assert hop.exists() 213 207 214 208 ··· 230 224 report = install_user(repo, home, ["claude"]) 231 225 232 226 assert report.error_count == 0 233 - assert (home / ".claude" / "skills" / "solstone").exists() 234 - assert not (home / ".codex" / "skills" / "solstone").exists() 227 + assert (home / ".claude" / "skills" / "sol").exists() 228 + assert not (home / ".codex" / "skills" / "sol").exists() 229 + 230 + 231 + def test_install_user_leaves_existing_solstone_bundle_untouched(tmp_path): 232 + repo = _mini_user_repo(tmp_path) 233 + home = _home(tmp_path, ".claude") 234 + old_bundle = home / ".claude" / "skills" / "solstone" 235 + old_bundle.mkdir(parents=True) 236 + (old_bundle / "SKILL.md").write_bytes(b"old bundle") 237 + 238 + report = install_user(repo, home, ["claude"]) 239 + 240 + assert report.error_count == 0 241 + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 242 + assert (old_bundle / "SKILL.md").read_bytes() == b"old bundle" 243 + assert all(row.skill != "solstone" for row in report.rows) 235 244 236 245 237 246 def test_install_project_creates_symlinks(tmp_path): ··· 243 252 assert report.error_count == 0 244 253 for agent_dir in [".claude", ".agents"]: 245 254 link_parent = target / agent_dir / "skills" 246 - for name in ["journal", "routines", "bar"]: 255 + for name in ["journal", "sol"]: 247 256 link = link_parent / name 248 257 assert link.is_symlink() 249 258 assert os.readlink(link) == os.path.relpath( 250 - repo 251 - / "solstone" 252 - / ("talent" if name != "bar" else "apps/foo/talent") 253 - / name, 259 + repo / "solstone" / "talent" / name, 254 260 link_parent, 255 261 ) 262 + assert {path.name for path in link_parent.iterdir()} == {"journal", "sol"} 256 263 257 264 258 265 def test_install_project_idempotent(tmp_path): ··· 279 286 repo = _mini_project_repo(tmp_path) 280 287 target = tmp_path / "work" 281 288 install_project(repo, target, ["all"]) 282 - shutil.rmtree(repo / "solstone" / "talent" / "routines") 289 + stale = target / ".claude" / "skills" / "entities" 290 + stale.symlink_to( 291 + os.path.relpath( 292 + repo / "solstone" / "apps" / "foo" / "talent" / "bar", stale.parent 293 + ) 294 + ) 283 295 284 296 report = install_project(repo, target, ["all"]) 285 297 286 298 assert report.error_count == 0 287 - assert not (target / ".claude" / "skills" / "routines").exists() 299 + assert not stale.exists() 288 300 assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) 289 301 290 302 291 - def test_install_project_dedupe_error(tmp_path): 292 - repo = tmp_path / "repo" 293 - _write_skill(repo / "solstone" / "talent" / "foo") 294 - _write_skill(repo / "solstone" / "apps" / "x" / "talent" / "foo") 303 + def test_install_project_preserves_obsolete_user_directory_with_warning(tmp_path): 304 + repo = _mini_project_repo(tmp_path) 305 + target = tmp_path / "work" 306 + obsolete = target / ".claude" / "skills" / "entities" 307 + obsolete.mkdir(parents=True) 308 + (obsolete / "SKILL.md").write_bytes(b"user content") 309 + 310 + report = install_project(repo, target, ["all"]) 311 + 312 + assert (obsolete / "SKILL.md").read_bytes() == b"user content" 313 + assert report.error_count == 0 314 + warning = next(row for row in report.rows if row.path == obsolete) 315 + assert warning.action == "warning" 316 + assert warning.skill == "entities" 317 + assert warning.reason == "user content at stale target preserved" 318 + 319 + 320 + def test_install_project_dedupe_error(monkeypatch, tmp_path): 321 + repo = _mini_project_repo(tmp_path) 322 + monkeypatch.setattr(skills_cli, "ROUTER_SKILL_NAMES", ("sol", "sol")) 295 323 296 324 with pytest.raises(ValueError) as exc_info: 297 325 install_project(repo, tmp_path / "work", ["all"]) 298 326 299 327 message = str(exc_info.value) 300 - assert str(repo / "solstone" / "talent" / "foo") in message 301 - assert str(repo / "solstone" / "apps" / "x" / "talent" / "foo") in message 328 + assert "duplicate skill name 'sol'" in message 302 329 303 330 304 331 def test_install_project_agent_claude_only(tmp_path): ··· 384 411 385 412 rows = list_user_status(user_repo, home, ["all"]) 386 413 387 - assert ("claude", "solstone", "installed") in { 414 + assert ("claude", "sol", "installed") in { 388 415 (row.agent, row.skill, row.state) for row in rows 389 416 } 390 - assert ("codex", "solstone", "not installed") in { 417 + assert ("codex", "sol", "not installed") in { 391 418 (row.agent, row.skill, row.state) for row in rows 392 419 } 393 420 ··· 419 446 420 447 captured = capsys.readouterr() 421 448 assert exit_code == 0 422 - assert "installed claude solstone" in captured.out 423 - assert (home / ".claude" / "skills" / "solstone" / "SKILL.md").exists() 449 + assert "installed claude sol" in captured.out 450 + assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 424 451 425 452 426 453 def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): ··· 442 469 443 470 result = resolve_user_skill() 444 471 445 - assert result.name == "solstone" 472 + assert result.name == "sol" 446 473 assert (result / "SKILL.md").is_file() 447 474 448 475 ··· 455 482 with pytest.raises(FileNotFoundError) as exc_info: 456 483 resolve_user_skill() 457 484 458 - assert "solstone/talent/solstone/SKILL.md" in str(exc_info.value) 485 + assert "solstone/talent/sol/SKILL.md" in str(exc_info.value) 459 486 460 487 monkeypatch.setenv("HOME", str(home)) 461 488 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) ··· 466 493 captured = capsys.readouterr() 467 494 assert exit_code == 1 468 495 assert "error:" in captured.err 469 - assert "solstone/talent/solstone/SKILL.md" in captured.err 496 + assert "solstone/talent/sol/SKILL.md" in captured.err 470 497 assert "Traceback" not in captured.err 498 + 499 + 500 + def test_main_build_generates_references(monkeypatch, tmp_path, capsys): 501 + output = tmp_path / "commands.md" 502 + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 503 + monkeypatch.setattr(skills_build, "build", lambda: [output]) 504 + monkeypatch.setattr(sys, "argv", ["sol skills", "build"]) 505 + 506 + exit_code = skills_cli.main() 507 + 508 + captured = capsys.readouterr() 509 + assert exit_code == 0 510 + assert f"generated {output}" in captured.out 511 + 512 + 513 + def test_main_build_check_green(monkeypatch, tmp_path, capsys): 514 + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 515 + monkeypatch.setattr(skills_build, "check", lambda: []) 516 + monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 517 + 518 + exit_code = skills_cli.main() 519 + 520 + captured = capsys.readouterr() 521 + assert exit_code == 0 522 + assert "generated skill references are current" in captured.out 523 + 524 + 525 + def test_main_build_check_reports_stale_path(monkeypatch, tmp_path, capsys): 526 + stale = tmp_path / "commands.md" 527 + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 528 + monkeypatch.setattr(skills_build, "check", lambda: [stale]) 529 + monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 530 + 531 + exit_code = skills_cli.main() 532 + 533 + captured = capsys.readouterr() 534 + assert exit_code == 1 535 + assert str(stale) in captured.err 536 + assert "run `sol skills build`" in captured.err
+2 -3
tests/test_sol_service_hard_error.py
··· 108 108 with pytest.raises(RuntimeError, match="execv called"): 109 109 sol_cli.main() 110 110 111 - # Shim routes through `journal start` (the canonical refresh-doing entry), 112 - # not `journal supervisor`, so the version-marker / wrapper / skill refresh 113 - # fires on this boot rather than waiting for the next restart. 111 + # Shim routes through `journal start`, the canonical service entry point, 112 + # not directly through `journal supervisor`. 114 113 execv.assert_called_once_with("/tmp/journal", ["/tmp/journal", "start", "5015"]) 115 114 116 115