diff --git a/AGENTS.md b/AGENTS.md index 168aabc5e..9a76eaa9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,13 +62,14 @@ Top-level dirs intentionally not in the table: `.venv/`, `scratch/`, `logs/`, `t - **Entities** — tracked people / projects / tools. Extracted from transcripts and accumulated across time. Canonical records in `journal/entities//entity.json`. - **Activities** — scheduled or observed "things that happen" (meetings, deadlines, anticipated events). Per-facet JSONL at `journal/facets//activities/.jsonl`. Sources: `anticipated` (from `solstone/talent/schedule.md`), `user` (manual), `cogitate` (talent-inferred). - **Indexer** — reads journal state, builds SQLite + FTS5 index. **Never** mutates source data (§7 L6). Rerunning on unchanged data is a no-op. -- **Supervisor** — top-level process manager. Starts/restarts services, talks to callosum. `sol supervisor` / `sol start`. +- **Supervisor** — top-level process manager. Starts/restarts services, talks to callosum. `journal supervisor` / `journal start`. ## 4. The sol CLI Two surfaces: -- **`sol `** — top-level commands registered in `solstone/think/sol_cli.py`'s `COMMANDS` dict (e.g., `sol import`, `sol think`, `sol indexer`, `sol supervisor`, `sol heartbeat`). `ALIASES` provides a couple of shorthand compound commands (`sol start` → `sol supervisor`, `sol up/down` → `sol service up/down`). +- **`sol `** — access commands registered in `solstone/think/sol_cli.py`'s `COMMANDS` dict (e.g., `sol import`, `sol indexer`, `sol top`, `sol health`). +- **`journal `** — host/service commands from the same registry (e.g., `journal think`, `journal supervisor`, `journal heartbeat`). `ALIASES` provides shorthand compound commands (`journal start` → `journal supervisor`, `journal up/down` → `journal service up/down`). - **`sol call `** — routes to `solstone/think/call.py`, which discovers each `solstone/apps/*/call.py` Typer sub-app and mounts it as a subcommand. Example: `sol call entities list`, `sol call activities create`, `sol call journal search`. **Adding a top-level command:** add an entry to `COMMANDS` in `solstone/think/sol_cli.py`; ensure the module has a `main()` function. @@ -135,7 +136,7 @@ Verified against `Makefile`. Grouped by use. ### Service management (systemd / launchd) -`.venv/bin/sol setup` is the source-checkout runtime install path after `make install`; it installs or refreshes the source-checkout wrapper, installs the Claude Code skill when Claude is configured, and starts the background service on port 5015 by default. After the first run, the wrapper at `~/.local/bin/sol` lets you use `sol setup` from anywhere. Use `sol service ` for manual service operations. +`.venv/bin/journal setup` is the source-checkout runtime install path after `make install`; it installs or refreshes the source-checkout wrappers, installs the Claude Code skill when Claude is configured, and starts the background service on port 5015 by default. After the first run, the wrappers at `~/.local/bin/sol` and `~/.local/bin/journal` let you use `sol` and `journal` from anywhere. Use `journal service ` for manual service operations. | Target | When to use | |--------|-------------| @@ -152,7 +153,7 @@ Verified against `Makefile`. Grouped by use. | Target | Why not | |--------|---------| -| `make uninstall` | Disabled by design. Use `sol service uninstall`, `sol skills uninstall`, and `python -m solstone.think.install_guard uninstall` for installed user artifacts, or `make clean-install` to rebuild the local dev env. | +| `make uninstall` | Disabled by design. Use `journal service uninstall`, `sol skills uninstall`, and `python -m solstone.think.install_guard uninstall` for installed user artifacts, or `make clean-install` to rebuild the local dev env. | ## 6. Testing quickstart @@ -243,7 +244,7 @@ Any function that handles a callosum event, a scheduled tick, or a supervisor-st The rules above govern *where* code lives. The rules below govern *how* code behaves. They exist because we got burned. - **No backwards-compatibility shims.** All code that depends on this project lives in this repository — never add fallback aliases, re-exports for moved symbols, deprecated-parameter handling, or legacy support code. When renaming or removing something, update every usage directly. For journal data-format changes, write a migration script (see `docs/APPS.md` for `maint` commands); do not add a compatibility layer. Cogitate agents default to adding shims; resist this. -- **Trust `get_journal()` unconditionally.** `get_journal()` from `solstone.think.utils` is the single source of truth for journal path resolution. For source-checkout installs, the managed bash wrapper at `~/.local/bin/sol` sets `SOLSTONE_JOURNAL` before invoking the venv `sol`; packaged installs use `uv tool install` / `pipx install` and rely on `get_journal()` for default-journal resolution; tests use the autouse fixture; Makefile sandboxes set it explicitly. Application code, agent prompts, subprocess environments, and service files must not set `SOLSTONE_JOURNAL` themselves. To rewrite the wrapper's embedded path use `sol config journal `. See `docs/environment.md`. +- **Trust `get_journal()` unconditionally.** `get_journal()` from `solstone.think.utils` is the single source of truth for journal path resolution. For source-checkout installs, the managed bash wrappers at `~/.local/bin/sol` and `~/.local/bin/journal` set `SOLSTONE_JOURNAL` before invoking the matching venv binary; packaged installs use `uv tool install` / `pipx install` and rely on `get_journal()` for default-journal resolution; tests use the autouse fixture; Makefile sandboxes set it explicitly. Application code, agent prompts, subprocess environments, and service files must not set `SOLSTONE_JOURNAL` themselves. To rewrite the wrapper's embedded path use `journal config journal `. See `docs/environment.md`. - **SPDX header on every source file.** All Python (and other source) files begin with: ```python @@ -309,7 +310,7 @@ The live journal also carries `journal/AGENTS.md` as its runtime-facing breadcru - **Not a runtime guide for cogitate talents.** Runtime CLI restrictions on talents live in `solstone/talent/journal/references/cli.md` § Talent CLI Boundaries. If you're tuning what a talent can or cannot call, look there, not here. - **Not the journal-layout reference.** `solstone/talent/journal/SKILL.md` + its `references/` is the cogitate-audience entry point. This file describes *how those commands are implemented*, not *which ones talents can't call*. -- **Not an operations manual.** For debugging a live system see `docs/DOCTOR.md`; for setup and service lifecycle, see [INSTALL.md](INSTALL.md) (owner install), [CONTRIBUTING.md](CONTRIBUTING.md) (developer install), `sol setup`, and `sol service`. +- **Not an operations manual.** For debugging a live system see `docs/DOCTOR.md`; for setup and service lifecycle, see [INSTALL.md](INSTALL.md) (owner install), [CONTRIBUTING.md](CONTRIBUTING.md) (developer install), `journal setup`, and `journal service`. ## 13. Owner-facing copy: the system-anatomy canon diff --git a/CHANGELOG.md b/CHANGELOG.md index ee61d4837..7388d6b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Format adapted from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), al - the built-in `sol observer install` command is gone. linux and tmux observers now install from their own published packages: `pipx install solstone-linux` (or `solstone-tmux`), `solstone-linux install-service` (or `solstone-tmux install-service`), then `sol observer create ` mints a key you give the observer. the macOS observer continues to come from the signed app bundle at solstone.app/observers. - the bundled per-provider install commands are gone — `sol call settings providers install` now accepts `local` only (cogitate runs out of the box for hosted providers with a key set), and `uninstall`/`disable`/`enable`/`validate-key` are removed entirely. local install continues to work via `sol call settings providers install local`. +## [0.3.10] — 2026-05-26 + +### Added +- **journal CLI** — `solstone` now installs two CLI binaries: `sol` (the day-to-day surface for talking to your journal — chat, call, top, import, etc.) and `journal` (host operations — supervisor, setup, install-models, the daemons that tend your journal). `sol --help` shows both surfaces; `journal --help` shows just the host commands. Existing `sol ` invocations all keep working. Internal docs and scripts use `journal ` for host operations going forward. + ## [0.3.9] - 2026-05-25 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e12767e26..4db05a37a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,18 +50,18 @@ brew install python git ripgrep ffmpeg uv git clone https://github.com/solpbc/solstone-journal.git cd solstone-journal make install -.venv/bin/sol setup +.venv/bin/journal setup ``` `make install` creates `.venv/`, syncs dependencies from `pyproject.toml` and `uv.lock`, installs the package in editable mode, and refreshes the project skill symlinks into the journal. -`.venv/bin/sol 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 wrapper at `~/.local/bin/sol`, and starts the background service. The default web interface listens on http://localhost:5015. Use `.venv/bin/sol setup --port 8000` to choose another port on the first run. +`.venv/bin/journal setup` runs doctor diagnostics, confirms the journal path, installs local transcription models, installs the `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. After the first setup run, the wrapper lets you use `sol` from anywhere: ```bash -sol service status -sol setup +journal service status +journal setup ``` The source-checkout journal lives at `journal/` inside the repo unless you pass `--journal` or have already configured another path. @@ -81,7 +81,7 @@ EOF chmod 600 journal/config/journal.json ``` -Run `sol password set` to configure web authentication. Replace `your-key-here` with your Google AI API key. Optional provider keys can be added to the same `env` object: +Run `journal password set` to configure web authentication. Replace `your-key-here` with your Google AI API key. Optional provider keys can be added to the same `env` object: ```json { @@ -98,7 +98,7 @@ Run `sol password set` to configure web authentication. Replace `your-key-here` ### Seeding a dev/test journal from public media -If you want a journal seeded with public-domain audio and screen recordings instead of your own capture data — useful for contributors who shouldn't be exposed to a maintainer's personal journal, integration-test scenarios, or a clean dev environment — see [docs/FIELD_JOURNAL.md](docs/FIELD_JOURNAL.md). The `setup_field_journal.sh` script at the repo root populates `journal/chronicle/` from a local clone of [solpbc/field_journal](https://github.com/solpbc/field_journal). It is opt-in and deliberately not part of `make install` or `sol setup`. +If you want a journal seeded with public-domain audio and screen recordings instead of your own capture data — useful for contributors who shouldn't be exposed to a maintainer's personal journal, integration-test scenarios, or a clean dev environment — see [docs/FIELD_JOURNAL.md](docs/FIELD_JOURNAL.md). The `setup_field_journal.sh` script at the repo root populates `journal/chronicle/` from a local clone of [solpbc/field_journal](https://github.com/solpbc/field_journal). It is opt-in and deliberately not part of `make install` or `journal setup`. ## Repo layout @@ -176,11 +176,11 @@ The packaged install (`uv tool install solstone`) installs `sol` to `~/.local/bi `make uninstall` is disabled by design. To migrate cleanly from a source checkout to a packaged install, remove user-runtime artifacts explicitly: ```bash -sol service uninstall +journal service uninstall sol skills uninstall python -m solstone.think.install_guard uninstall uv tool install solstone -sol setup +journal setup ``` Your journal is preserved at `~/journal`; solstone does not remove it during install or uninstall. Do not add backwards-compatibility shims for the old source-checkout layout. This migration is a clean break. diff --git a/INSTALL.md b/INSTALL.md index 8daba1b62..f651cde60 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -11,7 +11,7 @@ the latest version of these instructions is at https://solstone.app/install. ### check whether solstone is already installed ```bash -sol --version 2>&1 && sol service status 2>&1 +sol --version 2>&1 && journal service status 2>&1 ``` if `sol` isn't on PATH, the install hasn't been done yet — proceed. @@ -36,7 +36,7 @@ uv tool install solstone ## set up ```bash -sol setup +journal setup ``` this runs doctor diagnostics, 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. @@ -45,7 +45,7 @@ let your human know: **open http://localhost:5015 in a browser**. the first-run if a step has missing system libraries or python extras, `sol doctor` will tell you the exact install command to run for your platform. extras (`pdf`, `whisper`) can be added at any time with `uv tool upgrade solstone --extra pdf` or `pip install 'solstone[pdf]'`. on linux, local parakeet transcription needs `solstone[parakeet-onnx-cpu]` (or `[parakeet-onnx-cuda]` for NVIDIA GPUs); install or upgrade the same way as other extras. -if the service fails to start, check `sol service logs`. +if the service fails to start, check `journal service logs`. ## choosing how to power sol @@ -53,7 +53,7 @@ the sol agent is powered by an AI model, and you choose which. the choice has re - **a hosted provider key is the recommended way to start.** point solstone at Google (Gemini), OpenAI, or Anthropic with **your own developer API key**, created in that provider's developer console — *not* the consumer chat product (gemini.google.com / chatgpt.com / claude.ai). this is the fastest path to a working co-brain and what the first-run wizard sets up. cogitate (sol's tool-calling agent loop, used by chat/digest/morning_briefing/etc.) works out of the box as soon as you set a provider key — no extra install step. - **a local model via the local provider is a real, supported goal, but not the default daily experience yet.** running the sol agent fully locally means nothing leaves your machine. it's the maximum-privacy path, but it needs capable hardware and a local model with strong "thinking" support; smaller models on constrained machines (for example a base Mac mini) struggle on the reasoning-heavy work. treat local as a goal to grow into, not the recommended starting point. -- **on Apple Silicon, you can run sol's screen analysis on-device today.** macs with Apple Silicon and at least 16 GB of memory can turn on "MLX (Local, Apple Silicon)" in settings → providers; sol downloads a local model once, then does the work of making sense of your screen entirely on your machine, with nothing sent to a cloud provider. it's opt-in and covers screen analysis for now; the rest of sol stays on whichever provider you chose above. +- **on Apple Silicon, you can run sol's screen analysis on-device today.** macs with Apple Silicon and at least 16 GB of memory can turn on "MLX (Local, Apple Silicon)" in settings → providers; journal downloads a local model once, then does the work of making sense of your screen entirely on your machine, with nothing sent to a cloud provider. it's opt-in and covers screen analysis for now; the rest of sol stays on whichever provider you chose above. a hardware heads-up: local transcription alone installs a ~2.5 GB model, and a capable local *thinking* model needs meaningfully more memory and compute on top of that. if your machine is constrained, start with a hosted key and revisit local later; you can switch any time in settings → providers. @@ -88,14 +88,14 @@ sol observer create tmux-laptop ## upgrading ```bash -uv tool upgrade solstone && sol setup +uv tool upgrade solstone && journal setup ``` -(or `pipx upgrade solstone && sol setup`.) the second command refreshes the runtime artifacts and reconciles the service unit if anything has changed. +(or `pipx upgrade solstone && journal setup`.) the second command refreshes the runtime artifacts and reconciles the service unit if anything has changed. ## uninstall -1. remove setup-managed runtime files: `sol setup --clean-uninstall` +1. remove setup-managed runtime files: `journal setup --clean-uninstall` this removes the user service, managed `~/.local/bin/sol` wrapper, user config, and setup manifest. it does not remove your journal. 2. optional: remove agentic-tooling skills: `sol skills uninstall`. 3. uninstall the python package: `uv tool uninstall solstone` (or `pipx uninstall solstone`). diff --git a/Makefile b/Makefile index 41cecb226..3015debe2 100644 --- a/Makefile +++ b/Makefile @@ -108,7 +108,7 @@ install: .installed exit 1; \ fi @touch .installed - @$(VENV_BIN)/sol install-models || { echo "sol install-models failed" >&2; exit 1; } + @$(VENV_BIN)/journal install-models || { echo "journal install-models failed" >&2; exit 1; } # Setup skill symlinks skills: @@ -116,7 +116,7 @@ skills: # Start local dev stack against fixture journal (no observers, no daily processing) dev: .installed - $(TEST_ENV) PATH=$(CURDIR)/$(VENV_BIN):$$PATH $(VENV_BIN)/sol supervisor 0 --no-daily + $(TEST_ENV) PATH=$(CURDIR)/$(VENV_BIN):$$PATH $(VENV_BIN)/journal supervisor 0 --no-daily # Start sandbox stack: fixture copy + background supervisor + readiness wait sandbox: .installed @@ -138,7 +138,7 @@ sandbox: .installed echo "Sandbox journal: $$SANDBOX_JOURNAL"; \ # Boot supervisor in background \ SOLSTONE_JOURNAL="$$SANDBOX_JOURNAL" PATH=$(CURDIR)/$(VENV_BIN):$$PATH \ - $(VENV_BIN)/sol supervisor 0 --no-daily \ + $(VENV_BIN)/journal supervisor 0 --no-daily \ > "$$SANDBOX_JOURNAL/health/supervisor.log" 2>&1 & \ echo $$! > .sandbox.pid; \ echo "Supervisor PID: $$(cat .sandbox.pid)"; \ @@ -238,7 +238,7 @@ install-pinchtab: # Install and verify local ML models install-models: @test -x "$(VENV_BIN)/sol" || { echo "missing $(VENV_BIN)/sol; run make install first" >&2; exit 1; } - $(VENV_BIN)/sol install-models + $(VENV_BIN)/journal install-models # Build the parakeet helper binary (macOS/arm64 only, requires Xcode CLT) parakeet-helper: @@ -453,10 +453,10 @@ clean: # Follow installed service logs service-logs: - $(VENV_BIN)/sol service logs -f + $(VENV_BIN)/journal service logs -f uninstall: - @echo "Error: 'make uninstall' is disabled. Use 'sol service uninstall', 'sol skills uninstall', and 'python -m solstone.think.install_guard uninstall' to remove installed user artifacts, or 'make clean-install' to rebuild the local dev environment." >&2 + @echo "Error: 'make uninstall' is disabled. Use 'journal service uninstall', 'sol skills uninstall', and 'python -m solstone.think.install_guard uninstall' to remove installed user artifacts, or 'make clean-install' to rebuild the local dev environment." >&2 @exit 1 FORCE: diff --git a/README.md b/README.md index c8698ed1f..44547e537 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,10 @@ Python 3.11+, Linux + macOS, AGPL-3.0-only, maintained by [sol pbc](https://solp ```bash uv tool install solstone -sol setup +journal setup ``` -(or `pipx install solstone && sol setup`.) +(or `pipx install solstone && journal setup`.) then open http://localhost:5015 in a browser; the first-run wizard handles identity and the gemini API key. network access, and the password it requires, can be configured later in settings → security. @@ -81,13 +81,13 @@ see [INSTALL.md](INSTALL.md) for prerequisites, observer install, and troublesho ## CLI -solstone is operated through the unified `sol` command (33 subcommands). +solstone is operated through `sol` for day-to-day journal access and `journal` for host operations. ```bash sol # Status overview and command list -sol supervisor # Start the full stack (capture + processing + web) +journal supervisor # Start the full stack (capture + processing + web) sol chat # Interactive AI chat from the terminal -sol transcribe # Transcribe an audio file +journal transcribe # Transcribe an audio file sol indexer # Rebuild the search index ``` diff --git a/docs/APPS.md b/docs/APPS.md index c807a29f3..e8539cbc1 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -332,7 +332,7 @@ Example: - `context` is the full config dict with: `name`, `use_id`, `provider`, `model`, `prompt`, `output`, `meta`, and for generators: `day`, `segment`, `span`, `span_mode`, `transcript`, `output_path` - Return modified string, or `None` to use original result -**Flush hooks:** Segment agents can declare `"hook": {"flush": true}` to participate in segment flush. When no new segments arrive for an extended period, the supervisor triggers `sol think --flush --segment `, which runs only flush-enabled agents with `context["flush"] = True` and `context["refresh"] = True`. This lets agents close out dangling state (e.g., end active activities that would otherwise wait indefinitely for the next segment). The timeout is managed by the supervisor — agents should trust the flush signal without their own timeout logic. +**Flush hooks:** Segment agents can declare `"hook": {"flush": true}` to participate in segment flush. When no new segments arrive for an extended period, the supervisor triggers `journal think --flush --segment `, which runs only flush-enabled agents with `context["flush"] = True` and `context["refresh"] = True`. This lets agents close out dangling state (e.g., end active activities that would otherwise wait indefinitely for the next segment). The timeout is managed by the supervisor — agents should trust the flush signal without their own timeout logic. Hook errors are logged but don't crash the pipeline (graceful degradation). @@ -347,12 +347,12 @@ def post_process(result: str, context: dict) -> str | None: return result + "\n\n## Generated by hook" ``` -**Hook idempotency:** Post-hooks that write to shared journal state must be safe to run more than once on the same inputs. `sol think --refresh` bypasses the "output already exists" early-return in `solstone/think/talents.py` and re-executes the talent, which re-fires `post_process` against a fresh LLM result — so any side-effect the hook performs (writing events, appending to a log, updating an index file) will happen again. Pick one of these two patterns: +**Hook idempotency:** Post-hooks that write to shared journal state must be safe to run more than once on the same inputs. `journal think --refresh` bypasses the "output already exists" early-return in `solstone/think/talents.py` and re-executes the talent, which re-fires `post_process` against a fresh LLM result — so any side-effect the hook performs (writing events, appending to a log, updating an index file) will happen again. Pick one of these two patterns: - **Natural-key dedup.** Read the existing output, compute a natural key per row (e.g., `(facet, event_day, title, start, end)` for facet events), skip rows already present, and append only the new ones. Use this when the output is append-only history and you want to preserve prior writes from other agents. - **Atomic replace.** Recompute the full output, write it to a temp file, and rename into place. `atomic_write()` in `solstone/think/entities/core.py` is the established helper for text outputs; for JSONL, write the full set of lines to a tempfile and `os.replace()`. Use this when the hook owns the file end-to-end. -(Retired 2026-04-18 Sprint 4.) An earlier `write_events_jsonl` hook in `solstone/think/hooks.py` opened facet-event logs in `"a"` mode with no dedup and doubled row counts on every `sol think --refresh` — see the 2026-04-17 layer-violations audit (V6) tracked in sol pbc's internal engineering notes for the full write-up. +(Retired 2026-04-18 Sprint 4.) An earlier `write_events_jsonl` hook in `solstone/think/hooks.py` opened facet-event logs in `"a"` mode with no dedup and doubled row counts on every `journal think --refresh` — see the 2026-04-17 layer-violations audit (V6) tracked in sol pbc's internal engineering notes for the full write-up. See `docs/coding-standards.md` L8/L9 for the broader principles. @@ -474,7 +474,7 @@ Define one-time maintenance scripts that run automatically when supervisor start - Exit code 0 = success, non-zero = failure (failed tasks can be re-run with `--force`) - Use `setup_cli()` for consistent argument parsing and logging -**CLI:** `sol maint` (run pending), `sol maint --list` (show status), `sol maint --force` (re-run all) +**CLI:** `journal maint` (run pending), `journal maint --list` (show status), `journal maint --force` (re-run all) **Reference implementations:** - Example task: `solstone/apps/entities/maint/001_migrate_to_journal_entities.py` - real migration task demonstrating maint patterns diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index be9669232..c6e8cf7bf 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -13,7 +13,7 @@ Tactical work items prioritized for implementation. - [ ] Update supervisor/think interaction to use dynamic daily schedule from daily schedule agent output - [ ] Create segment agent for voiceprint detection and updating via hooks -- [ ] Surface named hook outputs in agents app and sol talent CLI +- [ ] Surface named hook outputs in agents app and journal talent CLI - [ ] Make daily schedule agents idempotent with state tracking (show existing vs new segments) - [ ] Add activities attach/update CLI tools for facet curation (like entity tools) diff --git a/docs/CONVEY.md b/docs/CONVEY.md index 471abe479..5e2ed5008 100644 --- a/docs/CONVEY.md +++ b/docs/CONVEY.md @@ -21,7 +21,7 @@ convey Password authentication is configured from Settings → Security when enabling network access. For headless setups, use the CLI: ```bash -sol password set +journal password set ``` When a password is set, it is stored as a secure hash in `config/journal.json` under `convey.password_hash`. diff --git a/docs/CORTEX.md b/docs/CORTEX.md index f75145cb5..a0b25acbe 100644 --- a/docs/CORTEX.md +++ b/docs/CORTEX.md @@ -313,7 +313,7 @@ All providers: ## Scheduled Agents and Generators -Both agents and generators support scheduling via `sol think`. Agents have `"schedule": "daily"` and generators have `"schedule": "segment"` or `"schedule": "daily"`. +Both agents and generators support scheduling via `journal think`. Agents have `"schedule": "daily"` and generators have `"schedule": "segment"` or `"schedule": "daily"`. ### Execution Order Scheduled items run in priority order (lower numbers first): @@ -384,10 +384,10 @@ Multi-facet segment agents spawn once per non-muted facet in this array. Muted f ## Process Management -The `sol supervisor` command provides process management for the Cortex ecosystem: +The `journal supervisor` command provides process management for the Cortex ecosystem: - Starts and monitors the Cortex file watcher service - Handles process restarts on failure - Monitors system health indicators -- Triggers `sol think` at midnight for daily processing (generators + agents) +- Triggers `journal think` at midnight for daily processing (generators + agents) This is distinct from agent lifecycle management, which Cortex handles internally through file state transitions. diff --git a/docs/DOCTOR.md b/docs/DOCTOR.md index 28279d412..2ab9131d1 100644 --- a/docs/DOCTOR.md +++ b/docs/DOCTOR.md @@ -25,15 +25,15 @@ ls journal/talents/*/*_active.jsonl 2>/dev/null ## Service Architecture -The supervisor (`sol supervisor`) manages these services: +The supervisor (`journal supervisor`) manages these services: | Service | Command | Purpose | Auto-restart | |---------|---------|---------|--------------| | Callosum | (in-process) | Message bus for inter-service events | No | | Observer | `sol observer` | Screen/audio capture (platform-detected) | Yes | -| Sense | `sol sense` | File detection, processing dispatch | Yes | +| Sense | `journal sense` | File detection, processing dispatch | Yes | -Cortex (agent execution) connects to Callosum but runs independently via `sol cortex`. +Cortex (agent execution) connects to Callosum but runs independently via `journal cortex`. See [CALLOSUM.md](CALLOSUM.md) for message protocol and [CORTEX.md](CORTEX.md) for agent system. @@ -44,7 +44,7 @@ See [CALLOSUM.md](CALLOSUM.md) for message protocol and [CORTEX.md](CORTEX.md) f | What | Where | |------|-------| | Current service logs | `journal/health/{service}.log` (symlinks) | -| Daemon stdout/stderr | `journal/health/service.log` (combined, append-only). The managed wrapper exports `PYTHONUNBUFFERED=1` for supervisor runs so stdout/stderr flush in real time and show up in `sol service logs` without a restart. | +| Daemon stdout/stderr | `journal/health/service.log` (combined, append-only). The managed wrapper exports `PYTHONUNBUFFERED=1` for supervisor runs so stdout/stderr flush in real time and show up in `journal service logs` without a restart. | | Day's process logs | `journal/{YYYYMMDD}/health/{ref}_{name}.log` | | Agent execution | `journal/talents//*.jsonl` | | Journal task log | `journal/task_log.txt` | @@ -199,7 +199,7 @@ socat - UNIX-CONNECT:journal/health/callosum.sock ### Unfinalized MOV Files (Missing moov Atom) -**Symptoms:** `sol describe` fails with `av.error.InvalidDataError: Invalid data found when processing input`. Sense logs show `describe failed ... exit code 1` and `Segment observed with errors ... ['describe exit 1']`. +**Symptoms:** `journal describe` fails with `av.error.InvalidDataError: Invalid data found when processing input`. Sense logs show `describe failed ... exit code 1` and `Segment observed with errors ... ['describe exit 1']`. **Diagnosis:** The `.mov` file has `ftyp` + `wide` + `mdat` atoms but is missing the `moov` atom. The `mdat` size is 0 (extends-to-EOF). This means the screen recorder (solstone-macos native app) never finalized the file — it wrote video frames but crashed or was interrupted before writing the metadata index. @@ -317,13 +317,13 @@ ffprobe -v error -show_streams /path/to/recovered.mov # Step 4: Replace original and re-run describe cp /path/to/recovered.mov /path/to/broken.mov -sol describe /path/to/broken.mov -v +journal describe /path/to/broken.mov -v ``` **Notes:** - The segment duration (DURATION_SECS) comes from the segment folder name (`HHMMSS_LEN` — LEN is duration in seconds) - The reference file must be from the same stream/session so codec parameters match -- PyAV (used by `sol describe`) bundles its own HEVC decoder, so this works even if system ffmpeg lacks one +- PyAV (used by `journal describe`) bundles its own HEVC decoder, so this works even if system ffmpeg lacks one - After recovery, run `sol indexer` if you need the new screen extracts searchable --- diff --git a/docs/FIELD_JOURNAL.md b/docs/FIELD_JOURNAL.md index c2c6631a8..69f071c69 100644 --- a/docs/FIELD_JOURNAL.md +++ b/docs/FIELD_JOURNAL.md @@ -2,7 +2,7 @@ `setup_field_journal.sh` (at the repo root) populates `journal/chronicle/` with content from [solpbc/field_journal](https://github.com/solpbc/field_journal) — a curated public-domain set of audio and screen recordings — so an instance of solstone runs against real, reproducible test material instead of personal capture data. -This is an **opt-in dev/test primitive**. It is not part of the canonical install or setup paths (`make install` and `sol setup` do not invoke it, and the script is deliberately not wired into the Makefile). Reach for it when you want: +This is an **opt-in dev/test primitive**. It is not part of the canonical install or setup paths (`make install` and `journal setup` do not invoke it, and the script is deliberately not wired into the Makefile). Reach for it when you want: - a contributor or contractor on solstone who shouldn't be exposed to a maintainer's personal journal, - an integration-test scenario seeded from stable, redistributable media, @@ -23,7 +23,7 @@ git clone https://github.com/solpbc/field_journal ~/Field_Journal ### 2. Scaffold the journal -If you don't already have a configured `journal/` (identity, providers, convey secret, facets), bootstrap one the normal way first — `make install` (source checkout) or `uv tool install solstone` (packaged) followed by `sol setup`, then whatever initial first-run wizard work brings the journal to a usable state. `setup_field_journal.sh` only populates `chronicle/`; it expects the rest of the journal scaffolding to already exist. +If you don't already have a configured `journal/` (identity, providers, convey secret, facets), bootstrap one the normal way first — `make install` (source checkout) or `uv tool install solstone` (packaged) followed by `journal setup`, then whatever initial first-run wizard work brings the journal to a usable state. `setup_field_journal.sh` only populates `chronicle/`; it expects the rest of the journal scaffolding to already exist. ### 3. Populate chronicle from field_journal @@ -38,7 +38,7 @@ Options: - `--source PATH` — field_journal clone location (default: `~/Field_Journal`). - `--force` — overwrite chronicle days that already exist. -After populating chronicle, the journal is ready for `sol setup` (if you haven't run it yet) or the running pipeline. +After populating chronicle, the journal is ready for `journal setup` (if you haven't run it yet) or the running pipeline. ## Running against an existing personal journal @@ -64,7 +64,7 @@ git -C ~/Field_Journal pull ## Running the pipeline -field_journal provides **media only** — transcripts, descriptions, entities, facets, and indexer state are not pre-built. After populating chronicle, run the stack (`sol up` / `make dev`) to have the think-side produce derived artifacts from the new media. +field_journal provides **media only** — transcripts, descriptions, entities, facets, and indexer state are not pre-built. After populating chronicle, run the stack (`journal up` / `make dev`) to have the think-side produce derived artifacts from the new media. ## Stream naming diff --git a/docs/OBSERVE.md b/docs/OBSERVE.md index 9e45d245e..0acd35bf8 100644 --- a/docs/OBSERVE.md +++ b/docs/OBSERVE.md @@ -37,10 +37,10 @@ sol observer revoke |---------|---------| | `sol observer` | Screen and audio capture (auto-detects platform) | | `sol observe-linux` | Screen and audio capture on Linux (direct) | -| `sol transcribe` | Audio transcription with faster-whisper | -| `sol describe` | Visual analysis of screen recordings | -| `sol grab` | Walk available screen frames and optionally write frame images | -| `sol sense` | Unified observation coordination | +| `journal transcribe` | Audio transcription with faster-whisper | +| `journal describe` | Visual analysis of screen recordings | +| `journal grab` | Walk available screen frames and optionally write frame images | +| `journal sense` | Unified observation coordination | ## Architecture @@ -51,9 +51,9 @@ Observer Ingest API (/app/observer/ingest/) ↓ Raw media files (*.flac, *.webm, tmux_*.jsonl) ↓ -sol sense (coordination) - ├── sol transcribe → audio.jsonl - └── sol describe → screen.jsonl +journal sense (coordination) + ├── journal transcribe → audio.jsonl + └── journal describe → screen.jsonl ``` ## Linux Observer State Machine diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 5cf6218c5..24eb36cca 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -35,7 +35,7 @@ COMMANDS: dict[str, str] = { Each module must export a `main()` function. The dispatcher does `importlib.import_module(path)` then calls `module.main()`. -Commands are organized into `GROUPS` for help display, and `ALIASES` provide shortcuts (e.g., `sol up` → `sol service up`). +Commands are organized into `GROUPS` for help display, and `ALIASES` provide shortcuts (e.g., `journal up` → `journal service up`). ### Adding a top-level command @@ -248,18 +248,18 @@ This is for audit trail — it records that the agent confirmed user consent bef Use lowercase, single-word names. Hyphenated names for multi-word (`list-nudges-due`, `set-name`). -## Structured output: `sol setup --jsonl` and `sol doctor --jsonl` +## Structured output: `journal setup --jsonl` and `sol doctor --jsonl` Use `--jsonl` when another process needs progress events as they happen. The contract is one JSON object per stdout line, flushed immediately; `sol doctor --jsonl` is mutually exclusive with `sol doctor --json`, and the existing `sol doctor --json` payload keeps its short statuses (`ok`, `warn`, `fail`, `skip`). | Event | Emitted by | When | |-------|------------|------| -| `setup.started` | `sol setup --jsonl` | Setup arguments are resolved and the run starts. | -| `setup.completed` | `sol setup --jsonl` | Setup reaches a terminal `ok` or `failed` state. | -| `step.started` | `sol setup --jsonl` | A setup step starts. | -| `step.completed` | `sol setup --jsonl` | A setup step finishes with `outcome: "ok"` or `outcome: "skipped"`. | -| `step.failed` | `sol setup --jsonl` | A setup step fails or reaches a dead end. | -| `step.warning` | `sol setup --jsonl` | Setup translates advisory diagnostics or dropped doctor lines. | +| `setup.started` | `journal setup --jsonl` | Setup arguments are resolved and the run starts. | +| `setup.completed` | `journal setup --jsonl` | Setup reaches a terminal `ok` or `failed` state. | +| `step.started` | `journal setup --jsonl` | A setup step starts. | +| `step.completed` | `journal setup --jsonl` | A setup step finishes with `outcome: "ok"` or `outcome: "skipped"`. | +| `step.failed` | `journal setup --jsonl` | A setup step fails or reaches a dead end. | +| `step.warning` | `journal setup --jsonl` | Setup translates advisory diagnostics or dropped doctor lines. | | `doctor.started` | `sol doctor --jsonl` | Doctor diagnostics begin. | | `check.completed` | `sol doctor --jsonl` | One diagnostic check finishes. Status is long form: `ok`, `warning`, `failed`, or `skipped`. | | `doctor.completed` | `sol doctor --jsonl` | Doctor diagnostics finish with `status: "ok"`, `"warning"`, or `"failed"`. | @@ -282,7 +282,7 @@ Skipped or resumed reasons are fixed: `--skip-models`, `--skip-skills`, `--skip- ### Doctor pass-through -`sol setup --jsonl` runs `sol doctor --jsonl` for the doctor step and forwards `doctor.started`, `check.completed`, and `doctor.completed` lines verbatim. Advisory doctor checks are also translated into setup-level `step.warning` events so consumers can handle setup warnings uniformly. +`journal setup --jsonl` runs `sol doctor --jsonl` for the doctor step and forwards `doctor.started`, `check.completed`, and `doctor.completed` lines verbatim. Advisory doctor checks are also translated into setup-level `step.warning` events so consumers can handle setup warnings uniformly. Example stream excerpt: diff --git a/docs/THINK.md b/docs/THINK.md index 62a128ff2..a326a51ac 100644 --- a/docs/THINK.md +++ b/docs/THINK.md @@ -16,19 +16,19 @@ The package exposes several commands: - `sol call transcripts read` groups audio and screen transcripts into report sections. Use `--start` and `--length` to limit the report to a specific time range. See `sol call transcripts --help` for additional commands. -- `sol think` runs generators and agents for a single day via Cortex. +- `journal think` runs generators and agents for a single day via Cortex. - `python -m solstone.think.talents` is the unified execution module for tool talents and generators spawned by Cortex (NDJSON protocol). -- `sol supervisor` monitors journaling health and starts the local services that feed Convey, Cortex, and related background tasks. Use the `--no-*` flags to opt out of specific services when debugging. -- `sol cortex` starts a Callosum-based service for managing AI agent instances and generators. -- `sol talent` lists available agents and generators with their configuration. Use `sol talent show ` to see details, and `sol talent show --prompt` to see the fully composed prompt that would be sent to the LLM. +- `journal supervisor` monitors journaling health and starts the local services that feed Convey, Cortex, and related background tasks. Use the `--no-*` flags to opt out of specific services when debugging. +- `journal cortex` starts a Callosum-based service for managing AI agent instances and generators. +- `journal talent` lists available agents and generators with their configuration. Use `journal talent show ` to see details, and `journal talent show --prompt` to see the fully composed prompt that would be sent to the LLM. ```bash sol call transcripts read YYYYMMDD [--start HHMMSS --length MINUTES] -sol think [--day YYYYMMDD] [--segment HHMMSS_LEN] [--stream NAME] [--refresh] [--flush] -sol supervisor [--no-daily] [--no-cortex] [--no-link] [--no-convey] [--no-schedule] -sol cortex [--host HOST] [--port PORT] [--path PATH] -sol talent list [--schedule daily|segment] [--json] -sol talent show [--prompt] [--day YYYYMMDD] [--segment HHMMSS_LEN] [--full] +journal think [--day YYYYMMDD] [--segment HHMMSS_LEN] [--stream NAME] [--refresh] [--flush] +journal supervisor [--no-daily] [--no-cortex] [--no-link] [--no-convey] [--no-schedule] +journal cortex [--host HOST] [--port PORT] [--path PATH] +journal talent list [--schedule daily|segment] [--json] +journal talent show [--prompt] [--day YYYYMMDD] [--segment HHMMSS_LEN] [--full] ``` Use `--refresh` to overwrite existing files, and `-v` for verbose logs. @@ -50,7 +50,7 @@ Tool access is command-based via the `sol call` CLI framework. ## Automating daily processing -The `sol think` command can be triggered by a systemd timer. Below is a +The `journal think` command can be triggered by a systemd timer. Below is a minimal service and timer that process yesterday's folder every morning at 06:00: @@ -60,7 +60,7 @@ Description=Process solstone journal [Service] Type=oneshot -ExecStart=/usr/local/bin/sol think +ExecStart=/usr/local/bin/journal think [Install] WantedBy=multi-user.target @@ -68,7 +68,7 @@ WantedBy=multi-user.target ```ini [Unit] -Description=Run sol think daily +Description=Run journal think daily [Timer] OnCalendar=*-*-* 06:00:00 @@ -83,7 +83,7 @@ WantedBy=timers.target ### Unified Priority Execution -All scheduled prompts (both generators and tool-using agents) share a unified priority system. The `sol think` command executes prompts ordered by priority, from lowest (runs first) to highest (runs last). +All scheduled prompts (both generators and tool-using agents) share a unified priority system. The `journal think` command executes prompts ordered by priority, from lowest (runs first) to highest (runs last). **Priority is required for all scheduled prompts.** Prompts without a `priority` field will fail validation. Suggested priority bands: @@ -98,7 +98,7 @@ After each generator completes and creates output, the indexer runs `--rescan-fi ### Cortex: Central Talent Manager -The Cortex service (`sol cortex`) is the central system for managing AI talent instances and generators. It monitors the journal's `talents/` directory for new requests and manages execution. All talent spawning should go through Cortex for proper event tracking and management. +The Cortex service (`journal cortex`) is the central system for managing AI talent instances and generators. It monitors the journal's `talents/` directory for new requests and manages execution. All talent spawning should go through Cortex for proper event tracking and management. Cortex routes requests based on configuration: - Requests with `tools` field → tool-using talents (`python -m solstone.think.talents`) @@ -222,7 +222,7 @@ AI agent system and tool-calling support for solstone. | Command | Purpose | |---------|---------| -| `sol cortex` | Agent orchestration service | +| `journal cortex` | Agent orchestration service | | `sol providers check` | Ad-hoc provider check (testing only) | ## Architecture diff --git a/docs/environment.md b/docs/environment.md index e66c8087f..b34d4ddb0 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -30,15 +30,15 @@ If you think you need to set `SOLSTONE_JOURNAL` from application code, fix the a ## Service Installation -There are two install paths and they handle journal resolution differently. For a fresh source checkout, `.venv/bin/sol setup` installs the managed bash wrapper at `~/.local/bin/sol` and then installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. After the first run, the wrapper lets you use `sol setup` from anywhere; override with `.venv/bin/sol setup --port 8000` on the first run or `sol setup --port 8000` after the wrapper exists. For packaged installs (`uv tool install solstone` or `pipx install solstone`), `sol` is installed directly at `~/.local/bin/sol` as the tool entry point — there is no managed bash wrapper, and `SOLSTONE_JOURNAL` is not exported in the service env block; the default journal location resolves via `get_journal()`. Both paths install the service. +There are two install paths and they handle journal resolution differently. For a fresh source checkout, `.venv/bin/journal setup` installs managed bash wrappers at `~/.local/bin/sol` and `~/.local/bin/journal`, then installs solstone as a systemd user service (Linux) or launchd agent (macOS) with convey on port 5015. After the first run, the wrappers let you use `sol` and `journal` from anywhere; override with `.venv/bin/journal setup --port 8000` on the first run or `journal setup --port 8000` after the wrapper exists. For packaged installs (`uv tool install solstone` or `pipx install solstone`), `sol` and `journal` are installed directly at `~/.local/bin/` as tool entry points — there are no managed bash wrappers, and `SOLSTONE_JOURNAL` is not exported in the service env block; the default journal location resolves via `get_journal()`. Both paths install the service. -Installed services invoke `~/.local/bin/sol`. They do **not** write `SOLSTONE_JOURNAL` into the service env block; the wrapper exports it before execing the venv `sol`. +Installed services invoke `~/.local/bin/journal`. They do **not** write `SOLSTONE_JOURNAL` into the service env block; the wrapper exports it before execing the venv `journal`. Use: -- `sol config show` to display the resolved journal path, user-facing source label, and wrapper status -- `sol config journal ` to atomically rewrite the wrapper's embedded journal path -- `sol service ` for service lifecycle management +- `journal config show` to display the resolved journal path, user-facing source label, and wrapper status +- `journal config journal ` to atomically rewrite the wrapper's embedded journal path +- `journal service ` for service lifecycle management ## API Keys diff --git a/pyproject.toml b/pyproject.toml index e61369396..ef1c00906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "solstone" -version = "0.3.9" +version = "0.3.10" description = "Navigate Life Intelligently" readme = "README.md" requires-python = ">=3.12" diff --git a/setup_field_journal.sh b/setup_field_journal.sh index 2a104eba1..739ce73e2 100755 --- a/setup_field_journal.sh +++ b/setup_field_journal.sh @@ -19,7 +19,7 @@ # writes derived artifacts (audio.jsonl, audio.npz, descriptions, etc.) as # siblings of source media, so symlinking would dirty the field_journal clone. # -# Run `sol setup` afterward to bring the journal to a ready-to-process state. +# Run `journal setup` afterward to bring the journal to a ready-to-process state. set -euo pipefail diff --git a/solstone/apps/import/guides/journal_archive.md b/solstone/apps/import/guides/journal_archive.md index 2ed2dd625..9e84a4047 100644 --- a/solstone/apps/import/guides/journal_archive.md +++ b/solstone/apps/import/guides/journal_archive.md @@ -18,7 +18,7 @@ If your other journal is on this same machine: ### About sol-transfer -`sol transfer` moves raw observations between machines — it doesn't move your merged journal, facets, entities, or import history. Use `sol call journal export` for that. +`journal transfer` moves raw observations between machines — it doesn't move your merged journal, facets, entities, or import history. Use `sol call journal export` for that. ## From another machine diff --git a/solstone/apps/settings/copy.py b/solstone/apps/settings/copy.py index 57b2a5150..c8b3ebc91 100644 --- a/solstone/apps/settings/copy.py +++ b/solstone/apps/settings/copy.py @@ -5,8 +5,8 @@ from __future__ import annotations -CONVEY_REFUSE_NO_PASSWORD_NETWORK = "error: enabling network access requires a password. set one first with: sol password set" -CONVEY_REFUSE_NO_PASSWORD_TRUST = "error: disabling localhost trust requires a password (otherwise no client could authenticate). set one first with: sol password set" +CONVEY_REFUSE_NO_PASSWORD_NETWORK = "error: enabling network access requires a password. set one first with: journal password set" +CONVEY_REFUSE_NO_PASSWORD_TRUST = "error: disabling localhost trust requires a password (otherwise no client could authenticate). set one first with: journal password set" CONVEY_NETWORK_ENABLE_PROGRESS = "enabling network access. restarting convey…" CONVEY_NETWORK_ENABLE_DONE = ( "network access enabled. convey is now reachable at: {host_url}" diff --git a/solstone/apps/settings/maint/002_restructure_stream_dirs.py b/solstone/apps/settings/maint/002_restructure_stream_dirs.py index 1898248b8..4d34a96a9 100644 --- a/solstone/apps/settings/maint/002_restructure_stream_dirs.py +++ b/solstone/apps/settings/maint/002_restructure_stream_dirs.py @@ -97,7 +97,7 @@ def restructure(journal_root: Path, dry_run: bool) -> None: if missing > 0: print( f"\nERROR: {missing} segments are missing stream.json markers.\n" - "Run 'sol maint settings:001_backfill_streams' first to tag all segments." + "Run 'journal maint settings:001_backfill_streams' first to tag all segments." ) raise SystemExit(1) diff --git a/solstone/apps/sol/maint/005_migrate_dream_to_think_schedules.py b/solstone/apps/sol/maint/005_migrate_dream_to_think_schedules.py index aa8105157..aa5aeb363 100644 --- a/solstone/apps/sol/maint/005_migrate_dream_to_think_schedules.py +++ b/solstone/apps/sol/maint/005_migrate_dream_to_think_schedules.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Rewrite stale `sol dream` schedule commands to `sol think`.""" +"""Rewrite stale `sol dream` schedule commands to `journal think`.""" from __future__ import annotations @@ -116,7 +116,7 @@ def _print_summary(summary: MigrationSummary) -> None: def main() -> None: parser = argparse.ArgumentParser( - description="Rewrite stale sol dream schedule commands to sol think." + description="Rewrite stale sol dream schedule commands to journal think." ) parser.add_argument( "--dry-run", diff --git a/solstone/convey/__init__.py b/solstone/convey/__init__.py index a29a7b347..5bd1c01de 100644 --- a/solstone/convey/__init__.py +++ b/solstone/convey/__init__.py @@ -67,7 +67,7 @@ def _migrate_setup_completed() -> None: """Infer setup.completed_at and set trust_localhost for existing installs. Legacy migration: handles journals where password_hash was set via - 'sol password set' CLI before web onboarding existed. Web onboarding + 'journal password set' CLI before web onboarding existed. Web onboarding now writes all config atomically in init_finalize(), so this path is only reached for pre-existing journals. """ diff --git a/solstone/convey/copy.py b/solstone/convey/copy.py index 85db511b4..b08498684 100644 --- a/solstone/convey/copy.py +++ b/solstone/convey/copy.py @@ -70,7 +70,7 @@ CONVEY_REPORT_MAILTO_BODY_PREFIX = ( ) CONVEY_REPORT_MAILTO_TRUNCATION_SUFFIX = "\n\n[full report is in your clipboard]\n" CONVEY_REPORT_BUTTON_LABEL = "Report this" -LOGIN_NO_PASSWORD_CONFIGURED = "no password is configured. set one in settings → security, or run 'sol password set' from a terminal on this machine." +LOGIN_NO_PASSWORD_CONFIGURED = "no password is configured. set one in settings → security, or run 'journal password set' from a terminal on this machine." SETTINGS_SECURITY_DESC = ( "network access and password protection for the convey web interface." ) diff --git a/solstone/convey/maint_cli.py b/solstone/convey/maint_cli.py index 0c5e1c8ae..53d219fde 100644 --- a/solstone/convey/maint_cli.py +++ b/solstone/convey/maint_cli.py @@ -4,10 +4,10 @@ """CLI for managing maintenance tasks. Usage: - sol maint # Run pending tasks - sol maint --list # Show status of all tasks - sol maint # Show task details and log output - sol maint --force # Re-run a specific task + journal maint # Run pending tasks + journal maint --list # Show status of all tasks + journal maint # Show task details and log output + journal maint --force # Re-run a specific task """ from __future__ import annotations @@ -71,7 +71,7 @@ def show_task_details(journal: Path, task_name: str) -> None: task = get_task_by_name(task_name) if not task: print(f"Task not found: {task_name}", file=sys.stderr) - print("Use 'sol maint --list' to see available tasks.", file=sys.stderr) + print("Use 'journal maint --list' to see available tasks.", file=sys.stderr) sys.exit(1) status, exit_code, ran_ts = get_task_status(journal, task.app, task.name) @@ -141,16 +141,16 @@ def show_task_details(journal: Path, task_name: str) -> None: def main() -> None: - """CLI entry point for sol maint command.""" + """CLI entry point for journal maint command.""" parser = argparse.ArgumentParser( description="Run maintenance tasks for apps", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - sol maint Run all pending maintenance tasks - sol maint --list Show status of all tasks - sol maint chat:fix_x Show task details and log output - sol maint -f fix_x Re-run a specific task + journal maint Run all pending maintenance tasks + journal maint --list Show status of all tasks + journal maint chat:fix_x Show task details and log output + journal maint -f fix_x Re-run a specific task """, ) parser.add_argument( @@ -213,12 +213,12 @@ Examples: if args.force: if not args.task: print("--force requires a task name.", file=sys.stderr) - print("Usage: sol maint --force ", file=sys.stderr) + print("Usage: journal maint --force ", file=sys.stderr) sys.exit(1) task = get_task_by_name(args.task) if not task: print(f"Task not found: {args.task}", file=sys.stderr) - print("Use 'sol maint --list' to see available tasks.", file=sys.stderr) + print("Use 'journal maint --list' to see available tasks.", file=sys.stderr) sys.exit(1) success, exit_code = run_task(journal, task) sys.exit(0 if success else exit_code) diff --git a/solstone/convey/root.py b/solstone/convey/root.py index 559327c23..4f6c413f7 100644 --- a/solstone/convey/root.py +++ b/solstone/convey/root.py @@ -245,7 +245,7 @@ def login() -> Any: session["logged_in"] = True session.permanent = True return redirect(url_for("root.index")) - error = "incorrect password. passwords are case-sensitive. if you've forgotten it, you can reset via sol password set on the command line." + error = "incorrect password. passwords are case-sensitive. if you've forgotten it, you can reset via journal password set on the command line." return render_template("login.html", error=error, no_password=False) diff --git a/solstone/convey/templates/init.html b/solstone/convey/templates/init.html index 0502d0a8b..daa788475 100644 --- a/solstone/convey/templates/init.html +++ b/solstone/convey/templates/init.html @@ -273,7 +273,7 @@ internal_error: 'something went wrong on this machine. try again.', unknown: 'something went wrong. try again, or finish setup with a pasted Gemini key instead.' }; - const MANUAL_KEY_PRESENT_COPY = 'a gemini key is already on this machine. to swap it for a scout-provisioned key, use `sol services enable scout --force` from a terminal.'; + const MANUAL_KEY_PRESENT_COPY = 'a gemini key is already on this machine. to swap it for a scout-provisioned key, use `journal services enable scout --force` from a terminal.'; const SCOUT_UNREACHABLE_COPY = "can't reach sol pbc right now."; let scoutStream = null; let scoutSubscribed = false; diff --git a/solstone/convey/tests/test_init_template_scout.py b/solstone/convey/tests/test_init_template_scout.py index b06939971..26e70465f 100644 --- a/solstone/convey/tests/test_init_template_scout.py +++ b/solstone/convey/tests/test_init_template_scout.py @@ -78,7 +78,7 @@ def test_init_scout_inline_script_contract(convey_env_setup_pending) -> None: assert "manage solstone scout anytime in your services" in html assert ( "a gemini key is already on this machine. to swap it for a " - "scout-provisioned key, use `sol services enable scout --force` from a " + "scout-provisioned key, use `journal services enable scout --force` from a " "terminal." ) in html assert "the consent link expired. try again." in html diff --git a/solstone/observe/export.py b/solstone/observe/export.py index 830ea6ec6..437f1dc25 100644 --- a/solstone/observe/export.py +++ b/solstone/observe/export.py @@ -4,8 +4,8 @@ """Export journal data to a remote solstone instance. Usage: - sol export --to URL --key KEY [--only TYPE] [--dry-run] [--day YYYYMMDD] - sol export --to LABEL [--only TYPE] [--dry-run] [--day YYYYMMDD] + journal export --to URL --key KEY [--only TYPE] [--dry-run] [--day YYYYMMDD] + journal export --to LABEL [--only TYPE] [--dry-run] [--day YYYYMMDD] """ from __future__ import annotations diff --git a/solstone/observe/grab.py b/solstone/observe/grab.py index 021aaf18e..3dc4d55d7 100644 --- a/solstone/observe/grab.py +++ b/solstone/observe/grab.py @@ -36,15 +36,15 @@ TABLE_LEVELS = { "3": ("screens", ["screen", "position", "connector", "frames_analyzed", "status"]), } NEXT_FOOTERS = { - "0": "Next: sol grab ", - "1": "Next: sol grab ", - "2": "Next: sol grab ", - "3": "Next: sol grab ", + "0": "Next: journal grab ", + "1": "Next: journal grab ", + "2": "Next: journal grab ", + "3": "Next: journal grab ", } LEVEL_4_FOOTER = ( - "Inspect: sol grab \n" - "Save one: sol grab --out PATH\n" - "Save many: sol grab " + "Inspect: journal grab \n" + "Save one: journal grab --out PATH\n" + "Save many: journal grab " ",,... --out PATH\n" "\n" "How extraction works:\n" @@ -57,7 +57,7 @@ LEVEL_4_PURGED_FOOTER = ( "Save mode unavailable: raw video has been purged by retention.\n" "Frame metadata above is still readable.\n" "\n" - "Inspect: sol grab " + "Inspect: journal grab " ) @@ -546,7 +546,7 @@ def save_frame_images( if bundle.jsonl_rel is not None: frame_id_token = ",".join(str(frame_id) for frame_id in frame_ids) command = ( - f"sol grab {day} {stream} {segment} {screen_token} {frame_id_token}" + f"journal grab {day} {stream} {segment} {screen_token} {frame_id_token}" ) raise FileNotFoundError( "raw video has been purged by retention; metadata-only access " @@ -658,9 +658,9 @@ def emit_output(payload: dict[str, Any], *, as_json: bool) -> None: print() print(json.dumps(data["frame"], indent=2)) print() - print("Save: sol grab --out PATH") + print("Save: journal grab --out PATH") print( - "Batch: sol grab " + "Batch: journal grab " ",,... --out PATH" ) return diff --git a/solstone/observe/transfer.py b/solstone/observe/transfer.py index 464a0f2b5..06caf361e 100644 --- a/solstone/observe/transfer.py +++ b/solstone/observe/transfer.py @@ -7,10 +7,10 @@ Provides export, import, and send commands for transferring fully-processed observation segments between solstone instances. Usage: - sol transfer export --day YYYYMMDD [--output PATH] - sol transfer import --archive PATH [--dry-run] - sol transfer send --to URL --key KEY [--day YYYYMMDD] [--dry-run] - sol transfer send --to LABEL [--day YYYYMMDD] [--dry-run] + journal transfer export --day YYYYMMDD [--output PATH] + journal transfer import --archive PATH [--dry-run] + journal transfer send --to URL --key KEY [--day YYYYMMDD] [--dry-run] + journal transfer send --to LABEL [--day YYYYMMDD] [--dry-run] On the RECEIVING host (the machine you are sending TO), run `sol observer create ` to generate an observer API key, then pass it diff --git a/solstone/talent/heartbeat.md b/solstone/talent/heartbeat.md index 56a7d7202..a116fa1b2 100644 --- a/solstone/talent/heartbeat.md +++ b/solstone/talent/heartbeat.md @@ -29,14 +29,14 @@ If you find issues: update agency.md's `## system` section via ## Step 2: Check journal quality -Run `sol talent logs --daily -c 10` to review recent talent runs and -`sol talent logs --errors -c 10` for recent errors. Look for: +Run `journal talent logs --daily -c 10` to review recent talent runs and +`journal talent logs --errors -c 10` for recent errors. Look for: - Broken segments (transcription failures, missing talent output) - Processing gaps (capture with no think processing) - Orphaned entities (zero observations after 7+ days) If you find reprocessable issues (broken segments): reprocess them directly -with `sol think --segment`. Log the action in agency.md. +with `journal think --segment`. Log the action in agency.md. If you find issues that are NOT reprocessable segments: add to agency.md only. @@ -67,7 +67,7 @@ Prune resolved items older than 2 weeks. Keep agency.md under 80 lines. ## Step 4: Scan for curation opportunities First check if there are segments processed since the last heartbeat by reviewing -`sol talent logs --daily -c 1`. If there is recent activity (new segments processed), +`journal talent logs --daily -c 1`. If there is recent activity (new segments processed), run `sol call speakers suggest` and check for entity duplicates via `sol call entities` queries on high-activity facets. If no new segments have been processed, skip the speaker scan and go straight to entity duplicate checks. diff --git a/solstone/talent/journal/references/captures.md b/solstone/talent/journal/references/captures.md index 2dabb90f6..afe0b2c18 100644 --- a/solstone/talent/journal/references/captures.md +++ b/solstone/talent/journal/references/captures.md @@ -119,7 +119,7 @@ Pre-stream segments (created before stream identity was added) have no `stream.j Captures are the original binary media files recorded by observation tools. -`sol grab` walks observed screens from day to stream to segment to screen to frame. +`journal grab` walks observed screens from day to stream to segment to screen to frame. Without `--out` it lists what is available or shows one frame's details. With `--out` it writes one or more frame images using the suffix you choose. Use bare `screen` for single-screen segments. diff --git a/solstone/talent/journal/references/cli.md b/solstone/talent/journal/references/cli.md index febe80313..91bd04965 100644 --- a/solstone/talent/journal/references/cli.md +++ b/solstone/talent/journal/references/cli.md @@ -273,16 +273,16 @@ sol call journal news work --cursor 20260110 -n 5 Cogitate talents have access to all `sol` commands. The following infrastructure commands must never be called by talents, because they manage services and data pipelines that should only be operated by the supervisor or a human operator: -- `sol supervisor` / `sol start` -- `sol think` except heartbeat's targeted `sol think --segment` +- `journal supervisor` / `journal start` +- `journal think` except heartbeat's targeted `journal think --segment` - `sol import` -- `sol config` -- `sol cortex` +- `journal config` +- `journal cortex` - `sol providers check` - `sol callosum` - `sol observer` / `sol observe-*` -- `sol sense` -- `sol transcribe` / `sol describe` +- `journal sense` +- `journal transcribe` / `journal describe` - `sol indexer --reset` -Talents should use `sol call` commands for journal interaction and `sol health` / `sol talent logs` for diagnostics. +Talents should use `sol call` commands for journal interaction and `sol health` / `journal talent logs` for diagnostics. diff --git a/solstone/talent/journal/references/config.md b/solstone/talent/journal/references/config.md index 3cf1b624a..7d5359b20 100644 --- a/solstone/talent/journal/references/config.md +++ b/solstone/talent/journal/references/config.md @@ -45,13 +45,13 @@ The `convey` block contains settings for the web application: ```json { "convey": { - "password_hash": " Security or sol password set>" + "password_hash": " Security or journal password set>" } } ``` Fields: -- `password_hash` (string) – Hashed password for accessing the convey web application. Set via Settings → Security or `sol password set`. +- `password_hash` (string) – Hashed password for accessing the convey web application. Set via Settings → Security or `journal password set`. **UI Preferences:** The separate `config/convey.json` file stores UI/UX personalization (facet/app ordering, selected facet). All fields optional: @@ -149,7 +149,7 @@ For complete documentation of the prompt template system including all variable ## Transcribe configuration -The `transcribe` block configures audio transcription settings for `sol transcribe`: +The `transcribe` block configures audio transcription settings for `journal transcribe`: ```json { @@ -204,7 +204,7 @@ CLI flags can override settings: `--backend` selects the backend, `--cpu` forces ## Describe configuration -The `describe` block configures screen analysis settings for `sol describe`: +The `describe` block configures screen analysis settings for `journal describe`: ```json { diff --git a/solstone/talent/journal/references/facets.md b/solstone/talent/journal/references/facets.md index f4b6a58b0..f4496ada3 100644 --- a/solstone/talent/journal/references/facets.md +++ b/solstone/talent/journal/references/facets.md @@ -231,7 +231,7 @@ Activity records are created by the `activities` segment agent when it detects t 6. The synthesized summary updates the record's `description`, and may also fill `title` and `details` 7. Later CLI edits append to the record's `edits` log and may hide/unhide the record without changing its ID -**Segment flush:** If no new segments arrive for an extended period (1 hour), the supervisor triggers `sol think --flush` on the last segment. Agents that declare `hook.flush: true` (like `activities`) run with `flush=True` in their context, treating all remaining active activities as ended. This ensures activities are recorded promptly even when the owner stops working, and prevents cross-day data loss. +**Segment flush:** If no new segments arrive for an extended period (1 hour), the supervisor triggers `journal think --flush` on the last segment. Agents that declare `hook.flush: true` (like `activities`) run with `flush=True` in their context, treating all remaining active activities as ended. This ensures activities are recorded promptly even when the owner stops working, and prevents cross-day data loss. Records are written idempotently — duplicate IDs are skipped on re-runs. diff --git a/solstone/think/config_cli.py b/solstone/think/config_cli.py index 12d504261..30f0c7440 100644 --- a/solstone/think/config_cli.py +++ b/solstone/think/config_cli.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""sol config — show and rewrite the embedded journal path in the wrapper.""" +"""journal config — show and rewrite the embedded journal path in the wrapper.""" from __future__ import annotations @@ -15,11 +15,10 @@ from pathlib import Path from solstone.think.install_guard import ( alias_path, + alias_paths, + install_wrappers, parse_wrapper, - render_wrapper, validate_journal_path_for_wrapper, - wrapper_lock, - write_wrapper_atomic, ) from solstone.think.service import service_is_installed, service_is_running from solstone.think.utils import ( @@ -74,6 +73,7 @@ class JournalChange: yes: bool dry_run: bool sol_bin: str + service_bin: str alias: Path @@ -106,7 +106,7 @@ def _read_wrapper_status() -> tuple[str, str | None]: def _wrapper_refusal(alias: Path) -> str: return ( "sol config: refused: " - f"{alias} is not a managed wrapper (run 'sol setup' from the solstone " + f"{alias} is not a managed wrapper (run 'journal setup' from the solstone " "source checkout to install the wrapper first)" ) @@ -182,7 +182,7 @@ def _service_summary(change: JournalChange, decision: Decision) -> str: def render_plan(change: JournalChange, decision: Decision) -> str: lines = [ - "sol config journal - plan summary", + "journal config journal - plan summary", "", f"current: {change.current_path} ({_state_label(change.current_active)})", f"target: {change.target_path} ({_state_label(change.target_active)})", @@ -199,7 +199,7 @@ def render_plan(change: JournalChange, decision: Decision) -> str: [ "", "current journal is left intact. " - f"to re-adopt it later: sol config journal {change.current_path} --switch --yes", + f"to re-adopt it later: journal config journal {change.current_path} --switch --yes", ] ) @@ -208,27 +208,34 @@ def render_plan(change: JournalChange, decision: Decision) -> str: def _rewrite_wrapper(change: JournalChange) -> str | None: - try: - current_content = change.alias.read_text(encoding="utf-8") - except OSError as exc: - print( - f"sol config: refused: cannot read {change.alias}: {exc}", - file=sys.stderr, - ) - return None - - current = parse_wrapper(current_content) - if current is None: - print(_wrapper_refusal(change.alias), file=sys.stderr) - return None - target_str = str(change.target_path) - if current["journal"] == target_str: - return current["sol_bin"] + sol_bins: dict[str, str] = {} + for binary, alias in alias_paths().items(): + if not alias.exists() and not alias.is_symlink(): + if binary == "journal": + sol_bins[binary] = str(Path(change.sol_bin).with_name("journal")) + continue + print(_wrapper_refusal(alias), file=sys.stderr) + return None + if alias.is_symlink(): + print(_wrapper_refusal(alias), file=sys.stderr) + return None + try: + current_content = alias.read_text(encoding="utf-8") + except OSError as exc: + print( + f"sol config: refused: cannot read {alias}: {exc}", + file=sys.stderr, + ) + return None + current = parse_wrapper(current_content) + if current is None: + print(_wrapper_refusal(alias), file=sys.stderr) + return None + sol_bins[binary] = current["sol_bin"] - new_content = render_wrapper(target_str, current["sol_bin"]) - write_wrapper_atomic(change.alias, new_content) - return current["sol_bin"] + install_wrappers(target_str, sol_bins) + return sol_bins["journal"] def _service_command(sol_bin: str, subcommand: str) -> subprocess.CompletedProcess: @@ -244,7 +251,7 @@ def _maybe_restart_current_service(change: JournalChange) -> None: if not change.service_running: return try: - _service_command(change.sol_bin, "start") + _service_command(change.service_bin, "start") except FileNotFoundError as exc: print( f"sol config: rollback warning: could not restart service ({exc})", @@ -262,15 +269,14 @@ def _run_switch(change: JournalChange) -> int: ) return 1 - with wrapper_lock(): - try: - restart_sol = _rewrite_wrapper(change) - except OSError as exc: - print( - f"sol config: refused: cannot rewrite {change.alias}: {exc}", - file=sys.stderr, - ) - return 1 + try: + restart_sol = _rewrite_wrapper(change) + except OSError as exc: + print( + f"sol config: refused: cannot rewrite {change.alias}: {exc}", + file=sys.stderr, + ) + return 1 if restart_sol is None: return 1 @@ -290,7 +296,7 @@ def _run_switch(change: JournalChange) -> int: ) except FileNotFoundError as exc: print( - f"sol config: wrapper rewritten to {change.target_path} but service restart could not run ({exc}); restart manually", + f"sol config: wrapper rewritten to {change.target_path} but journal service restart could not run ({exc}); restart manually", file=sys.stderr, ) return 2 @@ -298,7 +304,7 @@ def _run_switch(change: JournalChange) -> int: if result.returncode != 0: print( "sol config: wrapper rewritten to " - f"{change.target_path} but 'sol service restart --if-installed' exited " + f"{change.target_path} but 'journal service restart --if-installed' exited " f"{result.returncode}; investigate and restart manually", file=sys.stderr, ) @@ -327,7 +333,7 @@ def _run_move(change: JournalChange) -> int: if change.service_running: try: - stop_result = _service_command(change.sol_bin, "stop") + stop_result = _service_command(change.service_bin, "stop") except FileNotFoundError as exc: print( f"sol config: could not stop service before move ({exc})", @@ -348,26 +354,25 @@ def _run_move(change: JournalChange) -> int: print(f"sol config: move failed: {exc}", file=sys.stderr) return 1 - with wrapper_lock(): + try: + restart_sol = _rewrite_wrapper(change) + except OSError as exc: + rollback_ok = True try: - restart_sol = _rewrite_wrapper(change) - except OSError as exc: - rollback_ok = True - try: - if target.exists(): - os.rename(target, current) - except OSError as rollback_exc: - rollback_ok = False - print( - f"sol config: rollback failed after wrapper write error: {rollback_exc}", - file=sys.stderr, - ) - _maybe_restart_current_service(change) - message = f"sol config: move failed during wrapper update: {exc}" - if rollback_ok: - message += "; restored original journal" - print(message, file=sys.stderr) - return 2 + if target.exists(): + os.rename(target, current) + except OSError as rollback_exc: + rollback_ok = False + print( + f"sol config: rollback failed after wrapper write error: {rollback_exc}", + file=sys.stderr, + ) + _maybe_restart_current_service(change) + message = f"sol config: move failed during wrapper update: {exc}" + if rollback_ok: + message += "; restored original journal" + print(message, file=sys.stderr) + return 2 if restart_sol is None: try: @@ -532,6 +537,7 @@ def build_change( yes=args.yes, dry_run=args.dry_run, sol_bin=sol_bin, + service_bin=str(Path(sol_bin).with_name("journal")), alias=alias_path, ) @@ -633,7 +639,7 @@ def cmd_journal( def main() -> int: - parser = argparse.ArgumentParser(prog="sol config") + parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="cmd", required=True) subparsers.add_parser("show", help="show the configured journal path and source") journal_parser = subparsers.add_parser( diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index 10a658970..f74b839d6 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -565,7 +565,7 @@ def stale_alias_symlink_check(args: Args) -> CheckResult: check, "fail", detail, - "run `sol setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", + "run `journal setup` from the repo that owns the wrapper, or remove `~/.local/bin/sol` manually if the repo is gone", ) @@ -585,7 +585,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", f"could not parse plist: {type(exc).__name__}: {exc}", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && journal setup", ) program_arguments = data.get("ProgramArguments") if not isinstance(program_arguments, list) or not program_arguments: @@ -593,7 +593,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", "plist is missing ProgramArguments[0]", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && journal setup", ) executable = Path(str(program_arguments[0])) if not executable.exists(): @@ -601,7 +601,7 @@ def launchd_stale_plist_check(args: Args) -> CheckResult: check, "fail", f"plist points to missing executable: {executable}", - "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && sol setup", + "rm ~/Library/LaunchAgents/org.solpbc.solstone.plist && journal setup", ) return make_result(check, "ok", f"launchd plist target exists ({executable})") diff --git a/solstone/think/heartbeat.py b/solstone/think/heartbeat.py index bc622681b..9474171fa 100644 --- a/solstone/think/heartbeat.py +++ b/solstone/think/heartbeat.py @@ -47,9 +47,8 @@ def _last_success_time(health_dir: Path) -> datetime | None: def main() -> None: - """Entry point for ``sol heartbeat``.""" + """Entry point for ``journal heartbeat``.""" parser = argparse.ArgumentParser( - prog="sol heartbeat", description="Run periodic self-check agent", ) parser.add_argument( diff --git a/solstone/think/install_guard.py b/solstone/think/install_guard.py index cf69c9f0c..8c09ae3cb 100644 --- a/solstone/think/install_guard.py +++ b/solstone/think/install_guard.py @@ -13,7 +13,7 @@ import sys from contextlib import contextmanager from enum import Enum from pathlib import Path -from typing import Iterator, TypedDict +from typing import Iterator, NamedTuple, TypedDict try: import userpath # type: ignore[import-not-found] @@ -23,35 +23,36 @@ except ImportError: # system python without the venv: doctor.stale_alias_symlin WRAPPER_TEMPLATE = """\ #!/bin/bash -# sol — managed by 'sol config'. Edits will be overwritten. -# managed-version: 6 +# {binary} — managed by 'journal config'. Edits will be overwritten. +# managed-version: 7 : "${{SOLSTONE_JOURNAL:={journal}}}" export SOLSTONE_JOURNAL SOL_BIN='{sol_bin}' # Warn when pyproject.toml or uv.lock is newer than .installed. # Skipped silently if .installed is absent. -REPO_ROOT="${{SOL_BIN%/.venv/bin/sol}}" +REPO_ROOT="${{SOL_BIN%/.venv/bin/{binary}}}" if [ -f "$REPO_ROOT/.installed" ]; then if [ "$REPO_ROOT/pyproject.toml" -nt "$REPO_ROOT/.installed" ] \\ || [ "$REPO_ROOT/uv.lock" -nt "$REPO_ROOT/.installed" ]; then - echo "sol: WARNING — venv is stale (pyproject.toml or uv.lock changed since last install). Run: cd $REPO_ROOT && make install" >&2 + echo "{binary}: WARNING — venv is stale (pyproject.toml or uv.lock changed since last install). Run: cd $REPO_ROOT && make install" >&2 fi fi if [ ! -x "$SOL_BIN" ]; then - printf 'sol: venv binary missing or not executable: %s\\n' "$SOL_BIN" >&2 + printf '{binary}: venv binary missing or not executable: %s\\n' "$SOL_BIN" >&2 exit 127 fi exec "$SOL_BIN" "$@" """ -WRAPPER_MARKER = "# managed-version: 6" -WRAPPER_VERSION = 6 +WRAPPER_MARKER = "# managed-version: 7" +WRAPPER_VERSION = 7 -_RE_MARKER = re.compile(r"(?m)^# managed-version: (?P[1-6])$") +_RE_MARKER = re.compile(r"(?m)^# managed-version: (?P[1-7])$") _RE_JOURNAL = re.compile(r'(?m)^: "\$\{SOLSTONE_JOURNAL:=(?P[^\n]*)\}"$') _RE_SOL_BIN = re.compile(r"(?m)^SOL_BIN='(?P(?:[^']|'\\'')*)'$") _INVALID_JOURNAL_CHARS = ("$", "`", '"', "\\", "\n") +_WRAPPER_BINARIES = ("sol", "journal") class AliasState(Enum): @@ -69,18 +70,46 @@ class ParsedWrapper(TypedDict): version: int +class _PathSnapshot(NamedTuple): + kind: str + content: bytes | None = None + mode: int | None = None + target: str | None = None + + +def alias_paths() -> dict[str, Path]: + return { + binary: Path.home() / ".local" / "bin" / binary for binary in _WRAPPER_BINARIES + } + + def alias_path() -> Path: - return Path.home() / ".local" / "bin" / "sol" + return alias_paths()["sol"] + + +def journal_alias_path() -> Path: + return alias_paths()["journal"] -def expected_target(curdir: Path) -> Path: - return curdir / ".venv" / "bin" / "sol" +def expected_target(curdir: Path, binary: str = "sol") -> Path: + _validate_binary(binary) + return curdir / ".venv" / "bin" / binary -def render_wrapper(journal: str, sol_bin: str) -> str: - """Render the managed wrapper for ~/.local/bin/sol.""" +def _validate_binary(binary: str) -> None: + if binary not in _WRAPPER_BINARIES: + raise ValueError(f"unsupported wrapper binary: {binary}") + + +def render_wrapper(journal: str, sol_bin: str, binary: str) -> str: + """Render the managed wrapper for ~/.local/bin/.""" + _validate_binary(binary) escaped_sol_bin = sol_bin.replace("'", "'\\''") - return WRAPPER_TEMPLATE.format(journal=journal, sol_bin=escaped_sol_bin) + return WRAPPER_TEMPLATE.format( + binary=binary, + journal=journal, + sol_bin=escaped_sol_bin, + ) def parse_wrapper(content: str) -> ParsedWrapper | None: @@ -99,12 +128,103 @@ def parse_wrapper(content: str) -> ParsedWrapper | None: } -def write_wrapper_atomic(path: Path, content: str) -> None: - """Atomically rewrite the managed wrapper and restore exec mode.""" - from solstone.think.entities.core import atomic_write +def _snapshot_path(path: Path) -> _PathSnapshot: + if path.is_symlink(): + return _PathSnapshot("symlink", target=os.readlink(path)) + if path.exists(): + return _PathSnapshot( + "file", + content=path.read_bytes(), + mode=path.stat().st_mode & 0o777, + ) + return _PathSnapshot("absent") + + +def _restore_path(path: Path, snapshot: _PathSnapshot) -> None: + if path.exists() or path.is_symlink(): + path.unlink() + if snapshot.kind == "absent": + return + path.parent.mkdir(parents=True, exist_ok=True) + if snapshot.kind == "symlink": + assert snapshot.target is not None + path.symlink_to(snapshot.target) + return + if snapshot.kind == "file": + assert snapshot.content is not None + path.write_bytes(snapshot.content) + os.chmod(path, snapshot.mode if snapshot.mode is not None else 0o755) + return + raise RuntimeError(f"unknown wrapper snapshot kind: {snapshot.kind}") + + +def _staged_path(path: Path, index: int) -> Path: + return path.with_name(f".{path.name}.tmp-{os.getpid()}-{index}") + + +def write_wrappers_atomically(contents: dict[Path, str]) -> None: + """Atomically write one or more wrappers, rolling back all targets on failure.""" + snapshots = {path: _snapshot_path(path) for path in contents} + staged: dict[Path, Path] = {} + committed: list[Path] = [] + try: + for index, (path, content) in enumerate(contents.items()): + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = _staged_path(path, index) + if tmp_path.exists() or tmp_path.is_symlink(): + tmp_path.unlink() + tmp_path.write_text(content, encoding="utf-8") + os.chmod(tmp_path, 0o755) + staged[path] = tmp_path + for path, tmp_path in staged.items(): + os.replace(tmp_path, path) + committed.append(path) + except Exception: + for path in contents: + try: + _restore_path(path, snapshots[path]) + except Exception: + pass + raise + finally: + for path, tmp_path in staged.items(): + if path not in committed and (tmp_path.exists() or tmp_path.is_symlink()): + try: + tmp_path.unlink() + except OSError: + pass + + +def install_wrappers( + journal: str, + sol_bins: dict[str, str], + *, + paths: dict[str, Path] | None = None, +) -> None: + """Install both managed wrappers under one lock.""" + if paths is None: + paths = alias_paths() + contents = { + paths[binary]: render_wrapper(journal, sol_bins[binary], binary) + for binary in paths + } + with wrapper_lock(): + write_wrappers_atomically(contents) + - atomic_write(path, content) - os.chmod(path, 0o755) +def _install_wrappers_unlocked( + journal: str, + sol_bins: dict[str, str], + *, + paths: dict[str, Path] | None = None, +) -> None: + if paths is None: + paths = alias_paths() + contents = { + paths[binary]: render_wrapper(journal, sol_bins[binary], binary) + for binary in paths + } + write_wrappers_atomically(contents) @contextmanager @@ -130,11 +250,12 @@ def validate_journal_path_for_wrapper(path: str) -> None: ) -def check_alias(curdir: Path) -> tuple[AliasState, Path | None]: +def check_alias(curdir: Path, binary: str = "sol") -> tuple[AliasState, Path | None]: + _validate_binary(binary) if (curdir / ".git").is_file(): return AliasState.WORKTREE, None - alias = alias_path() + alias = alias_paths()[binary] if not alias.exists() and not alias.is_symlink(): return AliasState.ABSENT, None @@ -145,9 +266,9 @@ def check_alias(curdir: Path) -> tuple[AliasState, Path | None]: target = target.resolve() if not target.exists(): return AliasState.DANGLING, target - if target == expected_target(curdir).resolve(): + if target == expected_target(curdir, binary).resolve(): return AliasState.OWNED, target - packaged_target = (Path(sys.executable).parent / "sol").resolve() + packaged_target = (Path(sys.executable).parent / binary).resolve() if target == packaged_target: return AliasState.OWNED, target return AliasState.CROSS_REPO, target @@ -162,7 +283,7 @@ def check_alias(curdir: Path) -> tuple[AliasState, Path | None]: return AliasState.FOREIGN, None target = Path(parsed["sol_bin"]) - if target.resolve() == expected_target(curdir).resolve(): + if target.resolve() == expected_target(curdir, binary).resolve(): return AliasState.OWNED, target.resolve() return AliasState.FOREIGN, None @@ -179,40 +300,71 @@ def _current_journal_for_alias() -> str: return path -def check_alias_detail(curdir: Path) -> tuple[AliasState, str]: - """Return alias state plus the cmd_check token for owned aliases.""" - state, _other_target = check_alias(curdir) - if state is not AliasState.OWNED: - return state, state.value +def _alias_results(curdir: Path) -> list[tuple[str, Path, AliasState, Path | None]]: + return [ + (binary, alias, *check_alias(curdir, binary)) + for binary, alias in alias_paths().items() + ] + + +def _first_alias_state( + curdir: Path, + states: set[AliasState], +) -> tuple[str, Path, AliasState, Path | None] | None: + for result in _alias_results(curdir): + if result[2] in states: + return result + return None - alias = alias_path() - if alias.is_symlink(): - return state, "upgrade" +def _wrapper_is_current(curdir: Path, binary: str, alias: Path) -> bool: + if alias.is_symlink(): + return False try: content = alias.read_text(encoding="utf-8") except OSError: - return state, "upgrade" - + return False parsed = parse_wrapper(content) if parsed is None: - return state, "upgrade" - - if ( + return False + return ( parsed["journal"] == _current_journal_for_alias() - and parsed["sol_bin"] == str(expected_target(curdir)) + and parsed["sol_bin"] == str(expected_target(curdir, binary)) and parsed["version"] == WRAPPER_VERSION and (alias.stat().st_mode & 0o111) == 0o111 + ) + + +def check_alias_detail(curdir: Path) -> tuple[AliasState, str]: + """Return alias state plus the cmd_check token for owned aliases.""" + results = _alias_results(curdir) + if results[0][2] is AliasState.WORKTREE: + return AliasState.WORKTREE, AliasState.WORKTREE.value + + for _binary, _alias, state, _other_target in results: + if state in { + AliasState.CROSS_REPO, + AliasState.DANGLING, + AliasState.FOREIGN, + }: + return state, state.value + + if all(state is AliasState.ABSENT for _binary, _alias, state, _target in results): + return AliasState.ABSENT, "fresh" + + if all( + state is AliasState.OWNED and _wrapper_is_current(curdir, binary, alias) + for binary, alias, state, _target in results ): - return state, "current" + return AliasState.OWNED, "current" - return state, "upgrade" + return AliasState.OWNED, "upgrade" def format_error( state: AliasState, curdir: Path, - _alias: Path, + alias: Path, other_target: Path | None, *, allow_force: bool = False, @@ -230,12 +382,13 @@ def format_error( else: installed = " installed: not a symlink" + alias_label = _alias_label(alias) lines = [ - "ERROR: Another solstone install owns ~/.local/bin/sol.", + f"ERROR: Another solstone install owns {alias_label}.", f" this repo: {curdir}", installed, - "Run 'sol setup' from the installed repo first,", - "or remove ~/.local/bin/sol manually if that repo is gone.", + "Run 'journal setup' from the installed repo first,", + f"or remove {alias_label} manually if that repo is gone.", ] if allow_force: lines.append( @@ -244,6 +397,13 @@ def format_error( return "\n".join(lines) +def _alias_label(alias: Path) -> str: + local_bin = Path.home() / ".local" / "bin" + if alias.parent == local_bin: + return f"~/.local/bin/{alias.name}" + return str(alias) + + def _print_error( state: AliasState, curdir: Path, @@ -295,51 +455,60 @@ def _ensure_user_bin_on_path(user_bin: Path) -> None: def cmd_check(curdir: Path) -> int: - alias = alias_path() state, token = check_alias_detail(curdir) if state is AliasState.WORKTREE: print("worktree") + alias = alias_paths()["sol"] _print_error(state, curdir, alias, None) return 1 if state is AliasState.ABSENT: - print("fresh") + print(token) return 0 if state is AliasState.OWNED: print(token) return 0 if state is AliasState.CROSS_REPO: print("cross_repo") - _print_error(state, curdir, alias, check_alias(curdir)[1], allow_force=True) + result = _first_alias_state(curdir, {AliasState.CROSS_REPO}) + assert result is not None + _binary, alias, _state, other = result + _print_error(state, curdir, alias, other, allow_force=True) return 1 if state is AliasState.DANGLING: print("dangling") - _print_error(state, curdir, alias, check_alias(curdir)[1], allow_force=True) + result = _first_alias_state(curdir, {AliasState.DANGLING}) + assert result is not None + _binary, alias, _state, other = result + _print_error(state, curdir, alias, other, allow_force=True) return 1 if state is AliasState.FOREIGN: print("not_symlink") - _print_error(state, curdir, alias, None, allow_force=True) + result = _first_alias_state(curdir, {AliasState.FOREIGN}) + assert result is not None + _binary, alias, _state, other = result + _print_error(state, curdir, alias, other, allow_force=True) return 1 return 1 def cmd_install(curdir: Path, *, force: bool = False) -> int: - alias = alias_path() - state, other_target = check_alias(curdir) + blocked_states = { + AliasState.CROSS_REPO, + AliasState.DANGLING, + AliasState.FOREIGN, + } + worktree_result = _first_alias_state(curdir, {AliasState.WORKTREE}) - if state is AliasState.WORKTREE: + if worktree_result is not None: + _binary, alias, state, other_target = worktree_result _print_error(state, curdir, alias, other_target) return 1 - if ( - state - in { - AliasState.CROSS_REPO, - AliasState.DANGLING, - AliasState.FOREIGN, - } - and not force - ): + + blocked_result = _first_alias_state(curdir, blocked_states) + if blocked_result is not None and not force: + _binary, alias, state, other_target = blocked_result _print_error(state, curdir, alias, other_target, allow_force=True) return 1 @@ -350,22 +519,17 @@ def cmd_install(curdir: Path, *, force: bool = False) -> int: print(f"refused: {exc}", file=sys.stderr) return 1 - content = render_wrapper(journal, str(expected_target(curdir))) - alias.parent.mkdir(parents=True, exist_ok=True) + aliases = alias_paths() + sol_bins = {binary: str(expected_target(curdir, binary)) for binary in aliases} with wrapper_lock(): - locked_state, locked_other_target = check_alias(curdir) - if locked_state is AliasState.WORKTREE: + locked_worktree = _first_alias_state(curdir, {AliasState.WORKTREE}) + if locked_worktree is not None: + _binary, alias, locked_state, locked_other_target = locked_worktree _print_error(locked_state, curdir, alias, locked_other_target) return 1 - if ( - locked_state - in { - AliasState.CROSS_REPO, - AliasState.DANGLING, - AliasState.FOREIGN, - } - and not force - ): + locked_blocked = _first_alias_state(curdir, blocked_states) + if locked_blocked is not None and not force: + _binary, alias, locked_state, locked_other_target = locked_blocked _print_error( locked_state, curdir, @@ -374,38 +538,61 @@ def cmd_install(curdir: Path, *, force: bool = False) -> int: allow_force=True, ) return 1 - if alias.is_symlink(): - alias.unlink() - write_wrapper_atomic(alias, content) + try: + _install_wrappers_unlocked(journal, sol_bins, paths=aliases) + except OSError as exc: + print(f"ERROR: failed to install wrappers: {exc}", file=sys.stderr) + return 1 print("installed") - _ensure_user_bin_on_path(alias.parent) + _ensure_user_bin_on_path(aliases["sol"].parent) return 0 def cmd_uninstall(curdir: Path) -> int: - alias = alias_path() - state, other_target = check_alias(curdir) + blocked_states = { + AliasState.CROSS_REPO, + AliasState.DANGLING, + AliasState.FOREIGN, + } + worktree_result = _first_alias_state(curdir, {AliasState.WORKTREE}) - if state is AliasState.WORKTREE: + if worktree_result is not None: + _binary, alias, state, other_target = worktree_result _print_error(state, curdir, alias, other_target) return 1 - if state is AliasState.ABSENT: + results = _alias_results(curdir) + if all(state is AliasState.ABSENT for _binary, _alias, state, _target in results): print("absent") return 0 - if state is not AliasState.OWNED: + + blocked_result = _first_alias_state(curdir, blocked_states) + if blocked_result is not None: + _binary, alias, state, other_target = blocked_result _print_error(state, curdir, alias, other_target) return 1 with wrapper_lock(): - locked_state, locked_other_target = check_alias(curdir) - if locked_state is AliasState.ABSENT: + locked_worktree = _first_alias_state(curdir, {AliasState.WORKTREE}) + if locked_worktree is not None: + _binary, alias, locked_state, locked_other_target = locked_worktree + _print_error(locked_state, curdir, alias, locked_other_target) + return 1 + locked_results = _alias_results(curdir) + if all( + state is AliasState.ABSENT + for _binary, _alias, state, _target in locked_results + ): print("absent") return 0 - if locked_state is not AliasState.OWNED: + locked_blocked = _first_alias_state(curdir, blocked_states) + if locked_blocked is not None: + _binary, alias, locked_state, locked_other_target = locked_blocked _print_error(locked_state, curdir, alias, locked_other_target) return 1 - alias.unlink() + for _binary, alias, state, _target in locked_results: + if state is AliasState.OWNED: + alias.unlink() print("uninstalled") return 0 diff --git a/solstone/think/link/join_cli.py b/solstone/think/link/join_cli.py index 772e12429..0fe82bd77 100644 --- a/solstone/think/link/join_cli.py +++ b/solstone/think/link/join_cli.py @@ -13,7 +13,7 @@ otherwise `~/.config/solstone-observer/spl/