From 12c9efaeef0c3cf74aaec5bc09a9d82c21c388c1 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Thu, 11 Jun 2026 17:27:56 -0600 Subject: [PATCH] refactor(routines): excise owner-facing routines feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the owner-facing routines feature entirely: the routine talent, the scheduler engine (think/routines.py), the journal routines CLI, the 9 templates + SKILL, the suggestion engine (_routine_context), and every home-pulse / chat / exec surfacing. No replacement, no shims, no owner-data migration — journal/routines/ is left inert on disk. Tighten the cogitate journal-command allowlist 4->3 ({identity, health, talent}) across all three synced definitions. Surgically clean the kept consumer talents so no live run names `journal routines`. Regenerate the three affected API baselines. The MaintenanceRoutine system, coroutine call sites, and English-word prose are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/FIELD_JOURNAL.md | 2 +- docs/SOLCLI.md | 9 +- scripts/check_cogitate_prompts.py | 5 +- scripts/check_journal_io_mechanic.py | 1 - .../apps/entities/talent/entity_observer.md | 2 +- solstone/apps/home/routes.py | 178 +- solstone/apps/home/workspace.html | 189 +- solstone/talent/_routine_context.py | 228 -- solstone/talent/awareness_tender.md | 10 +- solstone/talent/chat.md | 9 +- solstone/talent/chat_context.py | 88 - solstone/talent/exec.md | 31 +- solstone/talent/exec_context.py | 39 - solstone/talent/facet_newsletter.md | 2 +- solstone/talent/partner.md | 2 +- solstone/talent/pulse.md | 5 +- solstone/talent/read.md | 5 +- solstone/talent/routine.md | 35 - solstone/talent/routines/SKILL.md | 148 -- .../routines/templates/commitment-audit.md | 38 - .../routines/templates/decision-review.md | 38 - .../talent/routines/templates/domain-watch.md | 39 - .../talent/routines/templates/energy-audit.md | 38 - .../talent/routines/templates/meeting-prep.md | 42 - .../routines/templates/monthly-patterns.md | 40 - .../routines/templates/morning-briefing.md | 42 - .../routines/templates/relationship-pulse.md | 45 - .../routines/templates/weekly-review.md | 40 - solstone/think/call.py | 1 - solstone/think/cogitate_policy.py | 2 +- solstone/think/routines.py | 633 ----- solstone/think/sol_cli.py | 1 - solstone/think/supervisor.py | 10 +- solstone/think/talent_cli.py | 2 +- solstone/think/tools/routines.py | 448 ---- tests/baselines/api/settings/providers.json | 8 - tests/baselines/api/sol/preview.json | 2 +- tests/baselines/api/sol/talents-day.json | 11 - tests/test_chat_context.py | 211 +- tests/test_chat_context_sol_initiated.py | 11 - tests/test_chat_runtime.py | 6 - tests/test_check_cogitate_prompts.py | 7 +- tests/test_cogitate_policy.py | 2 - tests/test_exec_context.py | 261 -- tests/test_home_reflections.py | 2 - tests/test_home_routines.py | 232 -- tests/test_home_yesterdays_processing.py | 2 - tests/test_local_command_home.py | 4 - tests/test_maintenance.py | 4 - tests/test_post_action_terminology.py | 2 - tests/test_routines.py | 2143 ----------------- tests/test_talent_cli.py | 3 +- tests/test_talent_fallback.py | 1 + 53 files changed, 39 insertions(+), 5320 deletions(-) delete mode 100644 solstone/talent/_routine_context.py delete mode 100644 solstone/talent/exec_context.py delete mode 100644 solstone/talent/routine.md delete mode 100644 solstone/talent/routines/SKILL.md delete mode 100644 solstone/talent/routines/templates/commitment-audit.md delete mode 100644 solstone/talent/routines/templates/decision-review.md delete mode 100644 solstone/talent/routines/templates/domain-watch.md delete mode 100644 solstone/talent/routines/templates/energy-audit.md delete mode 100644 solstone/talent/routines/templates/meeting-prep.md delete mode 100644 solstone/talent/routines/templates/monthly-patterns.md delete mode 100644 solstone/talent/routines/templates/morning-briefing.md delete mode 100644 solstone/talent/routines/templates/relationship-pulse.md delete mode 100644 solstone/talent/routines/templates/weekly-review.md delete mode 100644 solstone/think/routines.py delete mode 100644 solstone/think/tools/routines.py delete mode 100644 tests/test_exec_context.py delete mode 100644 tests/test_home_routines.py delete mode 100644 tests/test_routines.py diff --git a/docs/FIELD_JOURNAL.md b/docs/FIELD_JOURNAL.md index 69f071c69..64fab02a7 100644 --- a/docs/FIELD_JOURNAL.md +++ b/docs/FIELD_JOURNAL.md @@ -49,7 +49,7 @@ If your `journal/` already holds personal capture data and you want to switch to mv journal journal.bak-$(date +%Y%m%d) ``` -Then recreate the structural parts (config, identity, facets skeleton, tokens, link state, routines) in a fresh `journal/`, either by copying from the backup or by re-running setup. Do not carry over `chronicle/`, `indexer/`, `entities/`, or `health/` — those are derived and will be regenerated from field_journal media. +Then recreate the structural parts (config, identity, facets skeleton, tokens, link state) in a fresh `journal/`, either by copying from the backup or by re-running setup. Do not carry over `chronicle/`, `indexer/`, `entities/`, or `health/` — those are derived and will be regenerated from field_journal media. ## Refreshing after upstream updates diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 87d1916e2..a8ea6805f 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -102,9 +102,8 @@ call_app.add_typer(ledger_app, name="ledger") call_app.add_typer(profile_app, name="profile") ``` -Local-only service tools such as `journal navigate`, `journal routines`, and -`journal identity` are registered in `COMMANDS` instead of mounted under -`sol call`. +Local-only service tools such as `journal navigate` and `journal identity` are +registered in `COMMANDS` instead of mounted under `sol call`. ### Adding a new auto-discovered app @@ -367,7 +366,6 @@ solstone/ │ ├── tools/ │ │ ├── call.py # sol call journal (built-in) │ │ ├── navigate.py # journal navigate (built-in) -│ │ ├── routines.py # journal routines (built-in) │ │ └── sol.py # journal identity (built-in) │ └── *.py # Top-level command modules ├── solstone/apps/ @@ -383,7 +381,6 @@ solstone/ │ └── ... (web-only apps without call.py) ├── talent/ │ ├── journal/SKILL.md # Skills not tied to an app -│ ├── routines/SKILL.md │ ├── vit/SKILL.md │ └── *.md # Agent prompt files ├── journal/.agents/skills/ # Symlinks (generated by sol skills install --project; make skills wrapper) @@ -401,7 +398,7 @@ solstone/ | Group | Commands | |-------|----------| | Think (processing) | `import`, `think`, `planner`, `indexer`, `supervisor`, `schedule`, `maintenance`, `top`, `health`, `callosum`, `notify`, `heartbeat` | -| Service | `service` (+ aliases `up`, `down`, `start`), `navigate`, `routines`, `identity`, `settings`, `install-provider` | +| Service | `service` (+ aliases `up`, `down`, `start`), `navigate`, `identity`, `settings`, `install-provider` | | Observe (capture) | `transcribe`, `describe`, `sense`, `transfer`, `observer` | | Talent (AI agents) | `agents`, `cortex`, `talent`, `call`, `engage`, `providers` | | Convey (web UI) | `convey`, `restart-convey`, `maint` | diff --git a/scripts/check_cogitate_prompts.py b/scripts/check_cogitate_prompts.py index 7658122ff..1ded8f187 100644 --- a/scripts/check_cogitate_prompts.py +++ b/scripts/check_cogitate_prompts.py @@ -42,7 +42,7 @@ ROOT = Path(__file__).resolve().parent.parent # Must equal solstone/think/cogitate_policy.py:_JOURNAL_COMMANDS # (cogitate_policy.py:21). Duplicated here intentionally; no shared import. -ALLOWED_JOURNAL_COMMANDS = frozenset({"identity", "routines", "health", "talent"}) +ALLOWED_JOURNAL_COMMANDS = frozenset({"identity", "health", "talent"}) # Must equal solstone/think/cogitate_policy.py:_READ_TOOLS # (cogitate_policy.py:24). Duplicated here intentionally; no shared import. @@ -87,8 +87,7 @@ UNSUPPORTED_FLAGS: list[tuple[tuple[str, ...], str, str]] = [] ALLOWLIST: dict[tuple[str, str], int] = {} JOURNAL_ALTERNATIVE = ( - "use `journal` with one of {identity, routines, health, talent}, " - "or use `sol`/`sol call`" + "use `journal` with one of {identity, health, talent}, or use `sol`/`sol call`" ) READ_ALTERNATIVE = ( "use a bounded read tool: read_file, list_directory, glob, or grep_search" diff --git a/scripts/check_journal_io_mechanic.py b/scripts/check_journal_io_mechanic.py index 5968032e4..125450e47 100644 --- a/scripts/check_journal_io_mechanic.py +++ b/scripts/check_journal_io_mechanic.py @@ -107,7 +107,6 @@ EXCLUDED_FILES: frozenset[str] = frozenset( "solstone/think/runner.py", "solstone/think/providers_cli.py", "solstone/think/start.py", - "solstone/think/routines.py", # App-storage and temporary upload/transcription files. "solstone/apps/import/routes.py", "solstone/apps/support/routes.py", diff --git a/solstone/apps/entities/talent/entity_observer.md b/solstone/apps/entities/talent/entity_observer.md index 6dab30450..993aa140e 100644 --- a/solstone/apps/entities/talent/entity_observer.md +++ b/solstone/apps/entities/talent/entity_observer.md @@ -32,7 +32,7 @@ You must IGNORE operational items from context, including but not limited to: - Speaker cluster management or voice identification - Infrastructure issues, Convey errors, or ingest problems - System health checks or diagnostics -- Routine or schedule management +- Schedule management - Any maintenance or operational work outside entity observation Do not investigate, diagnose, or attempt to fix issues outside your mission. Do not activate health, speaker management, or codebase exploration tools. diff --git a/solstone/apps/home/routes.py b/solstone/apps/home/routes.py index 921176cc9..9e755f0f3 100644 --- a/solstone/apps/home/routes.py +++ b/solstone/apps/home/routes.py @@ -7,8 +7,7 @@ from __future__ import annotations import json import logging -import tempfile -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta from pathlib import Path from typing import Any from urllib.parse import quote @@ -925,162 +924,6 @@ def _summarize_yesterday_processing( } -def _freshness_hours(cadence) -> int: - """Return freshness window in hours based on routine cadence type.""" - if isinstance(cadence, dict): - return 24 - if isinstance(cadence, str): - fields = cadence.split() - if len(fields) == 5: - dom, dow = fields[2], fields[4] - if dom == "*" and dow == "*": - return 24 - return 168 - return 24 - - -def _extract_summary(output_path: Path) -> str: - """Extract a concise routine summary from a markdown output file.""" - try: - lines = output_path.read_text(encoding="utf-8").splitlines() - except OSError: - return "" - - if lines and lines[0].strip() == "---": - for i in range(1, len(lines)): - if lines[i].strip() == "---": - lines = lines[i + 1 :] - break - - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if len(stripped) > 80: - return stripped[:79] + "…" - return stripped - return "" - - -def _load_routines_state() -> dict[str, Any]: - """Load routines seen state from routines/state.json.""" - state_path = Path(get_journal()) / "routines" / "state.json" - if not state_path.exists(): - return {} - try: - with open(state_path, encoding="utf-8") as f: - raw = json.load(f) - except (json.JSONDecodeError, OSError): - return {} - return raw if isinstance(raw, dict) else {} - - -def _save_routines_state(state: dict[str, Any]) -> None: - """Persist routines seen state to routines/state.json.""" - routines_dir = Path(get_journal()) / "routines" - routines_dir.mkdir(parents=True, exist_ok=True) - state_path = routines_dir / "state.json" - - fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".state_") - tmp_file = Path(tmp_path) - try: - with open(fd, "w", encoding="utf-8") as f: - json.dump(state, f, indent=2) - tmp_file.replace(state_path) - except BaseException: - tmp_file.unlink(missing_ok=True) - raise - - -def _parse_seen_iso(value: str | None) -> datetime | None: - # Legacy state files persisted naive-UTC ISO; current writers persist aware UTC. - # Treat naive as UTC so both formats round-trip into a comparable aware datetime. - if not value: - return None - try: - parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) - except (ValueError, TypeError): - return None - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed - - -def _was_seen(item_time: datetime | None, last_seen: datetime | None) -> bool: - if item_time is None or last_seen is None: - return False - return item_time <= last_seen - - -def _collect_routines() -> list[dict[str, Any]]: - """Collect recent routine outputs for display.""" - from solstone.think.routines import get_config as get_routines_config - - try: - config = get_routines_config() - state = _load_routines_state() - last_seen_dt = _parse_seen_iso(state.get("routines_last_seen")) - - now = datetime.now(timezone.utc) - journal = Path(get_journal()) - routines = [] - - for value in config.values(): - if not isinstance(value, dict): - continue - if not value.get("enabled"): - continue - last_run = value.get("last_run") - if not last_run: - continue - - try: - parsed = datetime.fromisoformat(last_run.replace("Z", "+00:00")) - except (ValueError, AttributeError): - continue - last_run_dt = ( - parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) - ) - - freshness = _freshness_hours(value.get("cadence")) - if (now - last_run_dt).total_seconds() > freshness * 3600: - continue - - delta = now - last_run_dt - run_time_display = f"{relative_time(delta.total_seconds())} ago" - - routine_id = value.get("id", "") - output_dir = journal / "routines" / routine_id - summary = "" - if output_dir.exists(): - outputs = sorted( - output_dir.glob("*.md"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - if outputs: - summary = _extract_summary(outputs[0]) - - seen = _was_seen(last_run_dt, last_seen_dt) - - routines.append( - { - "id": routine_id, - "name": value.get("name", routine_id), - "last_run": last_run, - "run_time_display": run_time_display, - "summary": summary, - "seen": seen, - } - ) - - routines.sort(key=lambda r: r["last_run"], reverse=True) - return routines - except Exception: - logger.warning("home: failed to collect routines", exc_info=True) - return [] - - def _build_pulse_context() -> dict[str, Any]: """Build the full Pulse page context.""" today = _today() @@ -1132,7 +975,6 @@ def _build_pulse_context() -> dict[str, Any]: anticipated_activities = _collect_anticipated_activities(today) activities = _collect_activities(today) - routines = _collect_routines() latest_weekly_reflection = _load_latest_weekly_reflection() last_observe_relative = None @@ -1150,12 +992,10 @@ def _build_pulse_context() -> dict[str, Any]: briefing_exists = bool(briefing_sections) briefing_phase = _compute_briefing_phase(segment_count, now.hour, briefing_exists) briefing_lateness = _briefing_lateness_state(now, briefing_phase) - unseen_routines = [r for r in routines if not r["seen"]] show_welcome = ( narrative_content is None and not anticipated_activities and not activities - and not unseen_routines and not briefing_exists and not attention and not pulse_needs @@ -1169,11 +1009,6 @@ def _build_pulse_context() -> dict[str, Any]: if narrative_updated_at: narrative_summary += f" — updated {narrative_updated_at}" - routines_summary = "" - if unseen_routines: - n = len(unseen_routines) - routines_summary = f"{n} new routine{'s' if n != 1 else ''}" - today_summary_parts = [] if anticipated_activities: n = len(anticipated_activities) @@ -1246,7 +1081,6 @@ def _build_pulse_context() -> dict[str, Any]: "anticipated_activities": anticipated_activities, "activities": activities, "needs_you_items": [item.to_dict() for item in needs_you_items], - "routines": routines, "briefing_sections": briefing_sections, "briefing_meta": briefing_meta, "briefing_phase": briefing_phase, @@ -1260,7 +1094,6 @@ def _build_pulse_context() -> dict[str, Any]: "yesterday_processing": yesterday_processing, "show_welcome": show_welcome, "narrative_summary": narrative_summary, - "routines_summary": routines_summary, "today_summary": today_summary, "needs_summary": needs_summary, } @@ -1287,15 +1120,6 @@ def api_pulse(): return jsonify(ctx) -@home_bp.route("/api/routines/seen", methods=["POST"]) -def api_routines_seen(): - """Mark routines as seen.""" - state = _load_routines_state() - state["routines_last_seen"] = datetime.now(timezone.utc).isoformat() - _save_routines_state(state) - return jsonify({"ok": True}) - - @home_bp.route("/api/briefing") def api_briefing(): """Briefing-specific JSON for WebSocket-triggered refresh.""" diff --git a/solstone/apps/home/workspace.html b/solstone/apps/home/workspace.html index 24d301b11..dac4d46de 100644 --- a/solstone/apps/home/workspace.html +++ b/solstone/apps/home/workspace.html @@ -460,69 +460,6 @@ color: #4f46e5; } -/* Routines */ -.pulse-routines { - padding: 1.25rem; - background: #fff; - border-radius: 10px; - border: 1px solid #e2e8f0; -} - -.pulse-routines-list { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.pulse-routine-item { - font-size: 0.85rem; - color: #334155; - display: flex; - align-items: baseline; - gap: 0.5rem; - padding: 0.25rem 0.35rem 0.25rem 0; -} - -.pulse-routine-item::before { - content: "◆"; - color: #6366f1; - flex-shrink: 0; - font-size: 0.75rem; - width: 0.75rem; - text-align: center; - margin-left: -1.25rem; -} - -.pulse-routine-name { - font-weight: 500; -} - -.pulse-routine-time { - font-size: 0.75rem; - color: #94a3b8; -} - -.pulse-routine-summary { - color: #64748b; - font-size: 0.8rem; -} - -.pulse-routines-more { - font-size: 0.8rem; - color: #6366f1; - margin-top: 0.5rem; -} - -.pulse-routines-more a { - color: inherit; - text-decoration: none; -} - -.pulse-routines-more a:hover { - text-decoration: underline; - background: transparent; -} - /* Briefing Card */ .pulse-briefing-card { padding: 1.25rem; @@ -782,7 +719,6 @@ .pulse-reflection, .pulse-today, .pulse-needs, -.pulse-routines, .pulse-yesterday, .pulse-briefing-card, .pulse-empty-state { @@ -835,7 +771,6 @@ .pulse-facet-chip, .pulse-briefing-section-toggle, .pulse-section-toggle, - .pulse-routine-item, .pulse-event, .pulse-activity, .pulse-needs-item { @@ -854,7 +789,6 @@ .pulse-narrative, .pulse-reflection, - .pulse-routines, .pulse-today, .pulse-needs, .pulse-yesterday, @@ -872,7 +806,6 @@ .pulse-reflection, .pulse-today, .pulse-needs, - .pulse-routines, .pulse-yesterday, .pulse-briefing-card, .pulse-empty-state { @@ -881,14 +814,12 @@ .pulse-section-toggle::before, .pulse-needs-item::before, - .pulse-routine-item::before, .pulse-briefing-section-toggle::before { margin-left: 0; width: auto; } .pulse-needs-item, - .pulse-routine-item, .pulse-event, .pulse-activity { padding-left: 0.35rem; @@ -975,7 +906,7 @@ {% if show_welcome %}

welcome to your home page

-

this is where your day comes together — narrative summaries, calendar events, tasks, and routines. as observers experience your day along with you and sol curates, sections will appear here automatically.

+

this is where your day comes together — narrative summaries, calendar events, and tasks. as observers experience your day along with you and sol curates, sections will appear here automatically.

check system health →
{% endif %} @@ -1065,39 +996,6 @@ {% endif %} - - {% set unseen_routines = routines|selectattr('seen', 'equalto', false)|list %} - {% if unseen_routines %} -
- -
-
- {% for routine in unseen_routines %} -
- - {{ routine.name }} - {{ routine.run_time_display }} - {% if routine.summary %} - — {{ routine.summary }} - {% endif %} - -
- {% endfor %} -
- -
-
- {% endif %} - {% if not unseen_routines and routines and not show_welcome %} -
-

your routines

-
all caught up — no new routine results since you last checked.
-
- {% endif %} - {% if latest_weekly_reflection %}

weekly reflection

@@ -1312,9 +1210,6 @@ var dashboard = document.querySelector('.pulse-dashboard'); if (dashboard) { function dispatchConversationElement(el) { - if (el.closest('[data-routine-click]') || el.hasAttribute('data-routine-click')) { - fetch('/app/home/api/routines/seen', {method: 'POST'}); - } window.fillChat(el.dataset.conversation); } @@ -1407,11 +1302,6 @@ if (msg.event === 'finish' && msg.name === 'morning_briefing') refreshBriefing(); if (msg.event === 'error') refreshVitals(); }); - - // Routines: refresh on completion - window.appEvents.listen('routines', function(msg) { - if (msg.event === 'complete') refreshRoutines(); - }); } } @@ -1438,10 +1328,9 @@ }; // Section collapse/expand - var SECTION_IDS = ['pulse-narrative', 'pulse-routines', 'pulse-today', 'pulse-needs']; + var SECTION_IDS = ['pulse-narrative', 'pulse-today', 'pulse-needs']; var SECTION_DEFAULTS = { 'pulse-narrative': 'true', - 'pulse-routines': 'true', 'pulse-today': 'true', 'pulse-needs': 'false' }; @@ -1586,80 +1475,6 @@ }); } - async function refreshRoutines() { - try { - var data = await window.apiJson('/app/home/api/pulse'); - if (!Array.isArray(data.routines)) { - throw malformedHomeResponse('/app/home/api/pulse', 'Malformed pulse response'); - } - var container = document.getElementById('pulse-routines'); - var routines = data.routines.filter(function(r) { return !r.seen; }); - if (routines.length === 0) { - if (container) container.remove(); - return; - } - var wasCollapsed; - if (container) { - wasCollapsed = container.dataset.sectionCollapsed; - } else { - try { - var saved = JSON.parse(sessionStorage.getItem('pulse-section-state') || '{}'); - wasCollapsed = saved['pulse-routines'] || SECTION_DEFAULTS['pulse-routines']; - } catch(e) { - wasCollapsed = SECTION_DEFAULTS['pulse-routines']; - } - } - var expanded = wasCollapsed === 'false' ? 'true' : 'false'; - var summaryText = routines.length + ' new routine' + (routines.length !== 1 ? 's' : ''); - var html = '
'; - html += '

your routines

'; - html += '' + esc(summaryText) + ''; - html += '
'; - html += '
'; - html += '
'; - routines.forEach(function(r) { - var name = esc(r.name); - html += '
'; - html += '' + name + ' '; - html += '' + esc(r.run_time_display) + ''; - if (r.summary) html += ' — ' + esc(r.summary) + ''; - html += '
'; - }); - html += '
'; - html += '
'; - if (container) { - container.innerHTML = html; - container.dataset.sectionCollapsed = wasCollapsed; - } else { - var newDiv = document.createElement('div'); - newDiv.className = 'pulse-routines'; - newDiv.id = 'pulse-routines'; - newDiv.dataset.sectionCollapsed = wasCollapsed; - newDiv.innerHTML = html; - var narrative = document.getElementById('pulse-narrative'); - var today = document.getElementById('pulse-today'); - if (narrative) { - narrative.parentNode.insertBefore(newDiv, narrative.nextSibling); - } else if (today) { - today.parentNode.insertBefore(newDiv, today); - } else { - var dash = document.querySelector('.pulse-dashboard'); - if (dash) dash.appendChild(newDiv); - } - } - } catch (err) { - var routinesContainer = document.getElementById('pulse-routines'); - if (routinesContainer) { - routinesContainer.innerHTML = window.SurfaceState.error({ - heading: 'Couldn\'t refresh routines', - desc: window.CONVEY_COPY.RELOAD_HINT, - serverMessage: err?.serverMessage || err?.message || '', - detail: err, - }); - } - } - } - async function refreshBriefing() { try { var data = await window.apiJson('/app/home/api/briefing'); diff --git a/solstone/talent/_routine_context.py b/solstone/talent/_routine_context.py deleted file mode 100644 index 2f60f885b..000000000 --- a/solstone/talent/_routine_context.py +++ /dev/null @@ -1,228 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Shared rendering helpers for routine state template vars.""" - -from __future__ import annotations - -from datetime import date, timedelta -from typing import Any - -_TEMPLATE_TRIGGERS = { - "morning-briefing": { - "patterns": [ - "calendar", - "schedule", - "agenda", - "what do i have today", - "what's on my calendar", - "whats on my calendar", - "what's happening today", - "whats happening today", - ], - "threshold": 3, - "description": "asked about your calendar or schedule", - }, - "weekly-review": { - "patterns": [ - "this week", - "last week", - "past few days", - "how did my week", - "what happened this week", - "how was my week", - ], - "threshold": 3, - "description": "asked for week-scale synthesis", - }, - "domain-watch": { - "patterns": [ - "track", - "watch", - "keep an eye on", - "follow", - "across days", - "over time", - "lately", - "trend", - "trends", - ], - "threshold": 3, - "description": "revisited the same topic across multiple days", - }, - "relationship-pulse": { - "patterns": [ - "who haven't i", - "who havent i", - "relationship", - "when did i last talk to", - "catch up with", - ], - "threshold": 2, - "description": "asked about relationships", - }, - "commitment-audit": { - "patterns": [ - "follow up", - "follow-up", - "commitment", - "dropped", - "overdue", - "what did i promise", - "pending", - ], - "threshold": 2, - "description": "asked about commitments or follow-ups", - }, - "meeting-prep": { - "patterns": [ - "brief me", - "who am i meeting", - "meeting with", - "prepare me for", - "prep for my meeting", - "prep me for", - "meeting prep", - ], - "threshold": 3, - "description": "asked for meeting briefings", - }, -} - - -def render_active_routines() -> str: - """Render the active routines template var.""" - from solstone.think.routines import get_routine_state - - routines = get_routine_state() - if not routines: - return "" - - lines = ["## Active Routines\n"] - for routine in routines: - status = "on" if routine["enabled"] else "paused" - if routine.get("paused_until"): - status = f"paused until {routine['paused_until']}" - line = f"- **{routine['name']}** ({routine['cadence']}) — {status}" - if routine.get("output_summary"): - line += f" | recent: {routine['output_summary']}" - lines.append(line) - return "\n".join(lines) - - -def get_eligible_suggestion( - routines_config: dict[str, Any], journal_config: dict[str, Any] -) -> dict[str, Any] | None: - """Evaluate the routine suggestion gates and return the best candidate.""" - meta = routines_config.get("_meta", {}) - - if not meta.get("suggestions_enabled", True): - return None - - name_status = journal_config.get("agent", {}).get("name_status", "default") - if name_status == "default": - return None - - last_date_str = meta.get("last_suggestion_date") - if last_date_str: - try: - last_date = date.fromisoformat(last_date_str) - if (date.today() - last_date) < timedelta(days=7): - return None - except ValueError: - pass - - suggestions = meta.get("suggestions", {}) - active_templates = { - value.get("template") - for value in routines_config.values() - if isinstance(value, dict) and value.get("id") - } - - candidates = [] - - for template_name, entry in suggestions.items(): - if template_name in active_templates: - continue - if entry.get("response") == "declined": - continue - - info = _TEMPLATE_TRIGGERS.get(template_name) - if info and entry.get("trigger_count", 0) >= info["threshold"]: - candidates.append( - { - "template_name": template_name, - "trigger_count": entry["trigger_count"], - "first_trigger": entry.get("first_trigger"), - "pattern_description": info["description"], - } - ) - - if "monthly-patterns" not in active_templates: - mp_entry = suggestions.get("monthly-patterns", {}) - if mp_entry.get("response") != "declined": - from solstone.think.utils import day_dirs - - days = day_dirs() - if days: - earliest = min(days.keys()) - earliest_date = date( - int(earliest[:4]), - int(earliest[4:6]), - int(earliest[6:8]), - ) - if (date.today() - earliest_date) >= timedelta(days=30): - candidates.append( - { - "template_name": "monthly-patterns", - "trigger_count": 0, - "first_trigger": ( - f"{earliest[:4]}-{earliest[4:6]}-{earliest[6:8]}" - ), - "pattern_description": ( - "your journal has 30+ days of history" - ), - } - ) - - if not candidates: - return None - - candidates.sort(key=lambda candidate: candidate["trigger_count"], reverse=True) - return candidates[0] - - -def render_routine_suggestion() -> str: - """Render the routine suggestion template var.""" - from solstone.think.routines import get_config as get_routines_config - from solstone.think.utils import get_config as get_journal_config - - suggestion = get_eligible_suggestion(get_routines_config(), get_journal_config()) - if not suggestion: - return "" - - if suggestion["trigger_count"] == 0: - pattern_line = ( - f"Pattern: {suggestion['pattern_description']} " - f"since {suggestion['first_trigger']}." - ) - else: - pattern_line = ( - f"Pattern: You've {suggestion['pattern_description']} " - f"{suggestion['trigger_count']} times since " - f"{suggestion['first_trigger']}." - ) - - return ( - "## Routine Suggestion Eligible\n\n" - f"Template: {suggestion['template_name']}\n" - f"{pattern_line}\n" - f"Trigger count: {suggestion['trigger_count']}\n" - f"First seen: {suggestion['first_trigger']}\n\n" - "### Etiquette\n" - "- Mention this ONCE, naturally, at the end of your response\n" - '- Frame as observation: "I\'ve noticed you often... — would a routine help?"\n' - "- If $name declines or ignores, do not bring it up again this conversation\n" - "- After suggesting, run: `journal routines suggest-respond " - f"{suggestion['template_name']} --accepted` or `--declined`" - ) diff --git a/solstone/talent/awareness_tender.md b/solstone/talent/awareness_tender.md index 4318b07a3..f9b69848c 100644 --- a/solstone/talent/awareness_tender.md +++ b/solstone/talent/awareness_tender.md @@ -18,13 +18,12 @@ This is not a conversation. Gather state, write the update, done. ## Gather state -Read current state. Use `sol call` for indexed data, `read_file` for the -identity file, and the settled `journal routines` form for routine state: +Read current state. Use `sol call` for indexed data and `read_file` for the +identity file: 1. `sol call awareness status` — processing, import, and journal state 2. `sol call activities list --source anticipated` — today's scheduled activity records -3. `journal routines list` — active routines and recent outputs -4. `sol call entities search --limit 5` — recent entity activity +3. `sol call entities search --limit 5` — recent entity activity ## Write awareness.md @@ -40,9 +39,6 @@ segment: {$SOL_SEGMENT} ## activity - {current activity state from sense, 1-2 bullets} -## routines -- {active routines and last-run status, 1-3 bullets} - ## entities - {recent entity activity, 1-2 bullets} diff --git a/solstone/talent/chat.md b/solstone/talent/chat.md index 3386b7bd6..c1231ee71 100644 --- a/solstone/talent/chat.md +++ b/solstone/talent/chat.md @@ -20,10 +20,6 @@ You are this owner's local agent — not Google, OpenAI, Anthropic, or a generic $active_talents -$active_routines - -$routine_suggestion - $trigger_context ## How To Respond @@ -31,7 +27,6 @@ $trigger_context - **Default to a direct answer.** Most replies are short and direct, drawn from identity and recent chat. No dispatch. - **Match the owner's tone:** direct and brief for simple replies; warm when they're sharing something difficult; analytical when they need synthesis; challenging only when a pattern is worth naming. - **Don't fabricate.** If answering needs a journal fact you don't have on hand, dispatch `read` to find it rather than inventing. -- **Routine suggestions** (if any are in context) go once at the end, never on machine-driven follow-ups. - **Don't mention internal systems, hooks, or prompt assembly.** ## When To Dispatch A Talent @@ -50,8 +45,8 @@ a capability you lack — and pick the one that matches the *verb* of the reques or file evidence, or say it's thin — never synthesize a confident answer from a tool's error text. - `exec` — **do or change something.** Edit an entity, adjust an activity, - create or run a routine, set the journal name/owner. Dispatch only when the - owner clearly wants an action taken, and pass the specific change in the task. + set the journal name/owner. Dispatch only when the owner clearly wants an + action taken, and pass the specific change in the task. - `support` — **sol pbc help.** A bug report, a help request, feedback, or checking a ticket. The support talent files and monitors with sol pbc under the owner's consent. diff --git a/solstone/talent/chat_context.py b/solstone/talent/chat_context.py index d3d45c9e8..5b24799ad 100644 --- a/solstone/talent/chat_context.py +++ b/solstone/talent/chat_context.py @@ -15,13 +15,6 @@ from solstone.convey.sol_initiated.copy import ( SYNTHETIC_TRIGGER_LABEL, TRIGGER_LABEL_SOL_INITIATED, ) -from solstone.talent._routine_context import ( - _TEMPLATE_TRIGGERS as TEMPLATE_TRIGGERS, -) -from solstone.talent._routine_context import ( - render_active_routines, - render_routine_suggestion, -) logger = logging.getLogger(__name__) STOP_AND_REPORT_CONTRACT = ( @@ -31,67 +24,9 @@ STOP_AND_REPORT_CONTRACT = ( ) -def _count_triggers(msg: str, facet: str | None, config: dict) -> bool: - """Count trigger signals in the user's message. Returns True if config was mutated.""" - lower = msg.lower() - today = date.today().isoformat() - meta = config.setdefault("_meta", {}) - suggestions = meta.setdefault("suggestions", {}) - changed = False - - for template, info in TEMPLATE_TRIGGERS.items(): - if not any(p in lower for p in info["patterns"]): - continue - - if template == "domain-watch": - if not facet: - continue - entry = suggestions.setdefault( - template, - { - "trigger_count": 0, - "first_trigger": None, - "last_trigger": None, - "trigger_data": {}, - "response": None, - "suggested": False, - }, - ) - topics = entry.setdefault("trigger_data", {}).setdefault("topics", {}) - dates = topics.setdefault(facet, []) - if today not in dates: - dates.append(today) - entry["trigger_count"] = len(dates) - entry["first_trigger"] = entry["first_trigger"] or min(dates) - entry["last_trigger"] = max(dates) - changed = True - else: - entry = suggestions.setdefault( - template, - { - "trigger_count": 0, - "first_trigger": None, - "last_trigger": None, - "trigger_data": {}, - "response": None, - "suggested": False, - }, - ) - entry["trigger_count"] = entry.get("trigger_count", 0) + 1 - entry["first_trigger"] = entry.get("first_trigger") or today - entry["last_trigger"] = today - changed = True - - return changed - - def pre_process(context: dict) -> dict: """Build chat-context template vars for the chat talent prompt.""" - from solstone.think.routines import get_config as get_routines_config - from solstone.think.routines import save_config as save_routines_config - from solstone.think.utils import CorruptConfigError - facet = context.get("facet") trigger_kind, trigger_payload = _normalize_trigger(context) day = _resolve_day(context, trigger_payload) template_vars = { @@ -104,8 +39,6 @@ def pre_process(context: dict) -> dict: "since_ts": "", "trigger_talent": "", "location": "", - "active_routines": "", - "routine_suggestion": "", } result = {"template_vars": template_vars} @@ -170,27 +103,6 @@ def pre_process(context: dict) -> dict: template_vars["trigger_context"] = trigger_context template_vars["location"] = _render_location(trigger_payload, context) - try: - template_vars["active_routines"] = render_active_routines() - except Exception: - logger.debug("Routine state enrichment failed", exc_info=True) - - try: - prompt = context.get("prompt", "") - if trigger_kind == "owner_message" and prompt: - routines_config = get_routines_config() - if _count_triggers(prompt, facet, routines_config): - save_routines_config(routines_config) - except Exception: - logger.debug("Routine trigger counting failed", exc_info=True) - - try: - template_vars["routine_suggestion"] = render_routine_suggestion() - except CorruptConfigError: - raise - except Exception: - logger.debug("Routine suggestion eligibility check failed", exc_info=True) - return result diff --git a/solstone/talent/exec.md b/solstone/talent/exec.md index 4102a7b03..d7e9856ab 100644 --- a/solstone/talent/exec.md +++ b/solstone/talent/exec.md @@ -2,23 +2,16 @@ "type": "cogitate", "access_tier": "normal", "title": "Exec", - "description": "Sol — takes action and makes changes in the journal", - "hook": {"pre": "exec_context"} + "description": "Sol — takes action and makes changes in the journal" } $facets -## Current Routine State - -$active_routines - -$routine_suggestion - ## Your Job You make the change the owner asked for. You are the journal's hands: you edit -entities, adjust activities, manage routines, and set identity. You do exactly -the change requested — then confirm it in one line. +entities, adjust activities, and set identity. You do exactly the change +requested — then confirm it in one line. This is the *action* arm. You are not the lookup or synthesis arm (that's `read`) and not the support arm (that's `support`). If a request is really @@ -27,8 +20,7 @@ really "file a bug / get help," say it belongs to support. Don't pad an action with analysis the owner didn't ask for. You change journal state only through the `sol` command surface — there is no -general-purpose write tool. Every mutation below is a `sol call …` or an -approved `journal routines …` command. +general-purpose write tool. Every mutation below is a `sol call …` command. ## What You Can Change @@ -40,9 +32,6 @@ approved `journal routines …` command. | merge two entities | `sol call entities merge` | | move an entity between facets | `sol call entities move` | | mute / unmute an activity | `sol call activities mute` / `unmute` | -| create / edit / delete a routine | `journal routines create` / `edit` / `delete` | -| run a routine now | `journal routines run` | -| accept or decline a routine suggestion | `journal routines suggest-respond` | | name the journal / set the owner | `sol call sol set-name` / `set-owner` | If you don't know a command's exact options, check `sol call @@ -52,7 +41,7 @@ You do **not** create or cancel calendar events (calendar items come from the Calendar import and are read-only), create or edit activity *records* (you can only mute/unmute existing ones), manage to-dos, or manage owner skills — those surfaces aren't available here. If asked, say so plainly and offer what does -exist (e.g. an entity edit, a routine). +exist (e.g. an entity edit). ## Common Patterns (chain calls toward the goal) @@ -62,16 +51,12 @@ exist (e.g. an entity edit, a routine). - **"Note that Sam now leads the Atlas project."** — `entities search` to resolve Sam (read, to get the id) → `entities observe` / `entities update` to record it → one-line confirm. -- **"Remind me to review priorities every Monday."** — check `$active_routines` - / `journal routines templates` for a fit → `journal routines create` → - confirm the routine and its cadence. (If a routine *suggestion* is already in - context, prefer `journal routines suggest-respond` to accept it.) - **"Your name is Sol Prime now."** — `sol call sol set-name "Sol Prime"` → confirm. -Before a write that needs a target id (an entity, a routine), do the one read -needed to resolve it — then act. Keep reads to the minimum the action requires; -deep exploration is `read`'s job. +Before a write that needs a target id, do the one read needed to resolve it — +then act. Keep reads to the minimum the action requires; deep exploration is +`read`'s job. ## Confirm, Don't Narrate diff --git a/solstone/talent/exec_context.py b/solstone/talent/exec_context.py deleted file mode 100644 index 6c87a5ef3..000000000 --- a/solstone/talent/exec_context.py +++ /dev/null @@ -1,39 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Pre-hook: provide routine state template vars for exec.""" - -from __future__ import annotations - -import logging - -logger = logging.getLogger(__name__) - - -def pre_process(context: dict) -> dict: - """Build routine-state template vars for the exec talent prompt.""" - del context - - active_routines = "" - routine_suggestion = "" - - try: - from solstone.talent._routine_context import render_active_routines - - active_routines = render_active_routines() - except Exception: - logger.debug("exec_context: failed to render active routines", exc_info=True) - - try: - from solstone.talent._routine_context import render_routine_suggestion - - routine_suggestion = render_routine_suggestion() - except Exception: - logger.debug("exec_context: failed to render routine suggestion", exc_info=True) - - return { - "template_vars": { - "active_routines": active_routines, - "routine_suggestion": routine_suggestion, - } - } diff --git a/solstone/talent/facet_newsletter.md b/solstone/talent/facet_newsletter.md index 29d65d14b..e9bb5662e 100644 --- a/solstone/talent/facet_newsletter.md +++ b/solstone/talent/facet_newsletter.md @@ -33,7 +33,7 @@ You must IGNORE and EXCLUDE from your newsletters any operational items, includi - Speaker cluster management or voice identification - Infrastructure issues, Convey errors, or ingest problems - System health checks or diagnostics -- Routine or schedule management +- Schedule management - Any maintenance or operational work outside newsletter generation **Do not investigate, diagnose, or attempt to fix these issues. Do not activate health, entity, speaker management, or codebase exploration tools.** diff --git a/solstone/talent/partner.md b/solstone/talent/partner.md index 68ebbb509..6f6871aaa 100644 --- a/solstone/talent/partner.md +++ b/solstone/talent/partner.md @@ -12,7 +12,7 @@ $facets # Partner Profile You are updating sol's partner profile — a behavioral model of the journal owner -built from observed patterns. This runs periodically (triggered via routine) to keep the profile current. +built from observed patterns. This runs on a weekly cadence to keep the profile current. This is not a conversation. Gather data, observe patterns, update the profile, then call `emit_final`. diff --git a/solstone/talent/pulse.md b/solstone/talent/pulse.md index 005357c86..de2de6a23 100644 --- a/solstone/talent/pulse.md +++ b/solstone/talent/pulse.md @@ -24,11 +24,9 @@ Read current state. Use the `read_file` tool for the identity files (relative to 1. `read_file` `identity/pulse.md` — previous pulse (may not exist yet; that's fine) 2. `read_file` `identity/partner.md` — behavioral profile of the owner -3. `read_file` `identity/awareness.md` — current situational awareness (calendar, routines, activity, entities) +3. `read_file` `identity/awareness.md` — current situational awareness (calendar, activity, entities) 4. `sol call entities search` — recent entity activity -If — and only if — the awareness snapshot explicitly names a routine as having recent output, read that routine's latest with `journal routines output {routine_name}` — at most one call per explicitly-named routine. Do not guess routine names, try name variants, or fall back to `--help`. If no routine is named with recent output, skip this step entirely. - Note the key findings — you'll weave them into the narrative. ## Write the pulse @@ -41,7 +39,6 @@ morning of focused solo work followed by a tense meeting and a celebratory team has a shape. Name it when it's notable: "The afternoon shifted — three tense exchanges with the vendor, then a long quiet stretch." Don't force emotional language when the day is neutral. Only surface what's actually there. -If routines produced notable findings, reference them by name (e.g., 'Your Morning Briefing noted...'). After the narrative, include a `## needs you` section — a ranked list of 3-7 action items the owner should notice. Format as markdown bullet points: diff --git a/solstone/talent/read.md b/solstone/talent/read.md index 2f24a63e3..b857b5846 100644 --- a/solstone/talent/read.md +++ b/solstone/talent/read.md @@ -35,8 +35,8 @@ Your value is that you're grounded in the owner's actual history, not generic. ## How To Reach The Journal You reach the journal through the `sol` command surface (`sol call …`), -the settled `journal routines` / `journal identity` read forms, and the -`read_file` tool for raw files. Pick the right one; don't ask which. +the settled `journal identity` read forms, and the `read_file` tool for raw +files. Pick the right one; don't ask which. | To read… | Use | |----------|-----| @@ -44,7 +44,6 @@ the settled `journal routines` / `journal identity` read forms, and the | transcripts (what was said) | `sol call transcripts read` / `scan` / `segments` | | people, projects, relationships | `sol call entities search` / `list` / `show` / `observations` | | what's scheduled or happened | `sol call activities list` (add `--source anticipated` for calendar-derived items) / `get` | -| routines and their past output | `journal routines list` / `output` / `templates` | | identity & current state | `journal identity partner` / `pulse` / `briefing` | | speaker library | `sol call speakers status` / `suggest` | | system state | `sol call awareness status` | diff --git a/solstone/talent/routine.md b/solstone/talent/routine.md deleted file mode 100644 index f61ec55eb..000000000 --- a/solstone/talent/routine.md +++ /dev/null @@ -1,35 +0,0 @@ -{ - "type": "cogitate", - "title": "Routine", - "description": "User-defined routine execution — runs owner instructions on schedule", - "schedule": "none", - "priority": 10 -} - -$facets - -# Routine - -You are executing a user-defined routine. The owner has configured this routine -to run on a schedule with specific instructions. - -You operate at the `normal` cogitate capability surface — the `sol` command -line, bounded raw-evidence reads, and `emit_final` to finalize; there is no -general-purpose write tool and no outbound/send capability. - -Read the routine instruction carefully and execute it. Reach the journal through -`sol call` commands (and the settled `journal routines` / `journal identity` -forms) to query the journal, check entities, read transcripts, or perform what -the instruction requires; use the `read_file` / `glob` / `grep_search` tools for -raw evidence that has no `sol call` verb. Writes go only through `sol` domain -commands. - -If your instructions include a `Previous output:` line with a file path, read -that file first with the `read_file` tool for continuity — build on prior -results rather than starting from scratch. - -## Finalize - -Return your result via `emit_final(content=)` -exactly once — no preamble, lead with findings or actions. The system saves the -`content` argument as this routine's output. diff --git a/solstone/talent/routines/SKILL.md b/solstone/talent/routines/SKILL.md deleted file mode 100644 index 2eb497196..000000000 --- a/solstone/talent/routines/SKILL.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -name: routines -description: > - Recurring routines — daily briefings, weekly reviews, domain watches, - commitment audits, meeting prep, or custom automations. Create from - templates, adjust timing, pause/resume, run now, respond to suggestions. - TRIGGER: routine, schedule, recurring, automate, daily brief, weekly - review, journal routines create/list/edit/run. ---- - -# Routines CLI Skill - -Manage recurring routines. Invoke via Bash: `journal routines [args...]`. Never expose cron syntax, UUIDs, or CLI internals to the owner. - -## Template guidance - -| Template | When to propose | Default timing | What to ask about | -|----------|----------------|----------------|-------------------| -| `morning-briefing` | Wants a daily digest, morning summary, or "what's on my plate today" | Every morning at 7am | Which facets to include | -| `weekly-review` | Wants a weekly recap, reflection, or "how did my week go" | Friday evening | Which facets to cover, preferred day/time | -| `domain-watch` | Wants to track a topic, project, or area over time | Monday morning | Which domains/topics to watch, which facets | -| `relationship-pulse` | Wants to stay on top of key relationships or "who haven't I talked to" | Monday morning | Which facets, which relationships matter most | -| `commitment-audit` | Wants to catch dropped commitments, overdue items, or stale follow-ups | Monday morning | Which facets to audit | -| `monthly-patterns` | Wants a monthly retrospective or trend analysis | First of the month, morning | Which facets, what patterns matter | -| `meeting-prep` | Wants briefings before meetings — "prep me before each meeting" | 30 minutes before each calendar event | Which facets to draw context from | - -Meeting-prep is event-triggered, not clock-scheduled. Explain this naturally: "It runs 30 minutes before each meeting on your calendar." - -## Recognition - -Notice when the owner is asking for a routine, even when they don't use that word: - -- **Explicit scheduling:** "every morning, summarize my calendar" / "weekly, check in on the Acme deal" -- **Frustration with repetition:** "I keep forgetting to review my follow-ups on Friday" / "I always lose track of follow-ups" -- **Direct request:** "set up a routine" / "can you do this automatically?" - -## Creation conversation - -When you recognize routine intent, guide the owner through creation: - -1. **Propose a fit.** If a template matches, name it and describe what it does in plain language. If not, offer to build a custom routine. -2. **Confirm scope.** What facets should it cover? Default to all unless the intent clearly targets one area. -3. **Confirm timing.** Propose the template default in the owner's terms ("every morning at 7am", "Friday evening"). Let them adjust. -4. **Confirm timezone.** Default to the owner's local timezone from journal config. Only ask if ambiguous. -5. **Create and confirm.** Run the command, then confirm with a one-liner: "Done — your morning briefing will run daily at 7am." - -Always set `--timezone` to the owner's local timezone when creating routines, not UTC. - -## Custom routines - -When no template fits, build a custom routine: - -1. Ask the owner to describe what they want in plain language. -2. Draft a name, cadence in human terms, and instruction summary. Confirm with the owner. -3. Create it with explicit `--name`, `--instruction`, and `--cadence` flags. - -## Command reference - -| Intent | Command | -|--------|---------| -| Create from template | `journal routines create --template {template} --timezone {tz}` (add `--facets`, `--cadence` if overridden) | -| Create custom | `journal routines create --name "{name}" --instruction "{instruction}" --cadence "{cron}" --timezone {tz}` (add `--facets` if specified) | -| List all | `journal routines list` | -| Show templates | `journal routines templates` | -| Pause | `journal routines edit {name} --enabled false` | -| Resume | `journal routines edit {name} --enabled true` | -| Pause until date | `journal routines edit {name} --enabled false --resume-date {YYYY-MM-DD}` | -| Change cadence | `journal routines edit {name} --cadence "{cron}"` | -| Change facets | `journal routines edit {name} --facets "{comma-separated}"` | -| Change instruction | `journal routines edit {name} --instruction "{new instruction}"` | -| Delete | `journal routines delete {name}` | -| Run immediately | `journal routines run {name}` | -| Read output | `journal routines output {name}` (add `--date YYYY-MM-DD` for a specific day) | -| Toggle suggestions | `journal routines suggestions --enable` or `journal routines suggestions --disable` | -| Record response to a suggestion | `journal routines suggest-respond {template} --accepted` or `--declined` | -| Show suggestion state | `journal routines suggest-state` | - -Use the routine's name for identification, never UUIDs. - -## Management intents - -Handle routine management conversationally. The owner says what they want; you translate using the Command reference table above. - -- When the owner says "pause my morning briefing" or "stop the weekly review for now," you want to disable the routine. See Command reference for the exact edit form. -- When the owner says "turn my briefing back on" or "resume the weekly review," you want to re-enable it. See Command reference for the exact edit form. -- When the owner says "pause it until Monday," you want to disable it with a resume date. See Command reference for the exact edit form. -- When the owner says "move my briefing to 8am" or "make the review run on Sunday," you want to change the cadence. See Command reference for the exact edit form. -- When the owner says "add the work facet to my briefing" or "change the instruction to include...," you want to update facets or instruction. See Command reference for the exact edit forms. -- When the owner says "I don't need the weekly review anymore" or "remove that routine," you want to delete it after confirming. See Command reference for the exact delete form. -- When the owner says "what routines do I have?", you want to list routines and their status. See Command reference for the exact list form. -- When the owner says "what did my morning briefing say today?" or "show me last week's review," you want to read routine output. See Command reference for the exact output form. -- When the owner says "run my briefing now" or "do the weekly review right now," you want immediate execution. See Command reference for the exact run form. -- When the owner says "stop suggesting routines" or "turn routine suggestions back on," you want to toggle routine suggestions. See Command reference for the exact suggestions form. - -## Tone - -- Treat routines like setting an alarm — workmanlike, not ceremonial. "Done — morning briefing starts tomorrow at 7am." -- Never explain how routines work internally. The owner doesn't need to know about cron, agents, or output files. -- When the owner asks about routine output, present it as your own knowledge: "Your morning briefing found three meetings today and two overdue follow-ups." - -## Pre-hook context - -$active_routines - -When active routines appear above, they list each routine's name, cadence, status, and recent output summary. - -Use this to: -- Answer "what routines do I have?" without running a command -- Reference recent routine output naturally: "Your weekly review from Friday noted..." -- Notice when a routine is paused and offer to resume it if relevant - -When no routines appear above, the owner has no routines yet. Don't mention routines proactively — wait for the owner to express a need. - -## Progressive Discovery - -$routine_suggestion - -When a routine suggestion appears above, the owner's behavior matches a routine template. You did not request it — it was injected automatically. - -**How to handle:** -- Read the pattern description to understand why the suggestion is relevant -- Mention it ONCE, naturally, at the end of your response — never lead with it -- Frame as an observation: "I've noticed this comes up often — would a routine help?" -- If the owner declines or shows no interest, drop it immediately. Do not bring it up again this conversation. -- After the owner responds, record the outcome: - - Accepted: `journal routines suggest-respond {template} --accepted` - - Declined: `journal routines suggest-respond {template} --declined` - -**Never:** -- Suggest a routine without the eligible section in your context -- Push a suggestion after the owner declines or ignores it -- Mention the progressive discovery system or how suggestions work internally - -## Responding to suggestions - -When the system surfaces a routine suggestion and the owner accepts or declines it, record their response so the suggestion engine doesn't re-surface the same template prematurely: - -```bash -journal routines suggest-respond morning-briefing --accepted -journal routines suggest-respond weekly-review --declined -``` - -Exactly one of `--accepted` or `--declined` is required. `suggest-state` prints the full JSON state for all templates if you need to inspect what the engine already knows. - -## Gotchas - -- **Timezone must be an IANA name.** `--timezone America/Denver` works; `--timezone MDT` does not. The CLI rejects the latter with a terse error. -- **Suggestion responses are idempotent within a day.** Calling `suggest-respond` twice in the same day overwrites the previous response. Don't loop on it. diff --git a/solstone/talent/routines/templates/commitment-audit.md b/solstone/talent/routines/templates/commitment-audit.md deleted file mode 100644 index 0379da7b2..000000000 --- a/solstone/talent/routines/templates/commitment-audit.md +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "commitment-audit", - "description": "Audit open follow-ups and likely dropped commitments across facets.", - "default_cadence": "0 10 * * 1", - "default_timezone": "UTC", - "default_facets": [] -} - -You are auditing open commitments and follow-through. - -The goal is to surface what is overdue, stale, ambiguous, or at risk of being forgotten. - -## Gather - -1. Use `sol call journal search "" -a followups -n 20` to find follow-up items from recent journal activity. -2. Use `sol call journal facets` if you need to map commitments back to facets. -3. Use `sol call journal news FACET --day $day_YYYYMMDD` when a facet summary helps explain why something is still open. -4. Use `journal identity pulse` to compare explicit commitments with current focus and needs-you items. - -## Synthesize - -- Separate explicit follow-ups from implied commitments found in journal output. -- Highlight overdue items, stale items, and commitments without clear owners or timing. -- Merge duplicates and repeated reminders into a single entry. -- Call out places where current priorities do not match open obligations. - -## Write - -Produce markdown with sections such as: - -- `## Overdue` -- `## Stale or Ambiguous` -- `## Follow-Ups to Close` -- `## Recommended Cleanup` - -Use bullets ordered by urgency. -Keep the output practical and evidence-based. -Do not invent deadlines that are not present in the journal. diff --git a/solstone/talent/routines/templates/decision-review.md b/solstone/talent/routines/templates/decision-review.md deleted file mode 100644 index d2a008a2c..000000000 --- a/solstone/talent/routines/templates/decision-review.md +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "decision-review", - "description": "Monthly reflection on decisions captured in the journal — context, reasoning, and how they played out.", - "default_cadence": "0 10 15 * *", - "default_timezone": "UTC", - "default_facets": [] -} - -You are preparing a structured reflection on decisions from the past month. - -This is not a summary — it's a mirror. The goal is to help the owner see their own decision-making patterns clearly. - -## Gather - -1. Use `sol call journal search "" -a decisions --day-from START --day-to END -n 20` for the past 30 days of decision agent output. -2. Use `sol call journal search "" -a pulse --day-from START --day-to END -n 15` for narrative context around major decisions. -3. Use `sol call entities intelligence PERSON` for people involved in the most consequential decisions. -4. Use `sol call activities list --source anticipated --day YYYYMMDD` for days with major decisions to see what else was happening. -5. Use `journal identity partner` for the owner's known decision style. - -## Synthesize - -- Identify the 3-5 most consequential decisions from the month. -- For each: what was decided, what context surrounded it (calendar load, who was involved, what else was happening that day), and — if enough time has passed — what early signals suggest about how it's playing out. -- Look for patterns: Does the owner decide quickly under pressure but deliberate in calm periods? Do collaborative decisions stick better than solo ones? Are there decisions that keep getting revisited? -- Note any decisions that were avoided or deferred — sometimes what wasn't decided matters more than what was. - -## Write - -Structure the output as a reflection, not a report: - -- `## Decisions This Month` — The 3-5 most consequential, with context -- `## Patterns` — What the decision-making looked like as a whole -- `## Revisits` — Decisions that keep coming back or that early evidence suggests need adjustment -- `## One Question` — A single reflective question for the owner based on what the data shows - -Keep the tone direct and honest. Anchor everything in journal evidence. -Don't assign quality judgments to decisions — present the pattern and let the owner draw conclusions. diff --git a/solstone/talent/routines/templates/domain-watch.md b/solstone/talent/routines/templates/domain-watch.md deleted file mode 100644 index d7d6ecc1d..000000000 --- a/solstone/talent/routines/templates/domain-watch.md +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "domain-watch", - "description": "Recurring scan for trends and new mentions across important topics within the selected facets.", - "default_cadence": "0 8 * * 1", - "default_timezone": "UTC", - "default_facets": [] -} - -You are monitoring domains, topics, or recurring concerns across the routine's configured facets. - -Search the journal for meaningful changes, not just keyword repetition. - -## Gather - -1. Confirm the facets in scope with `sol call journal facets` if needed. -2. Use `sol call journal search QUERY --facet FACET --day-from START --day-to END -n 20` for each important topic or domain you can infer from the routine context. -3. Use `sol call journal news FACET --day $day_YYYYMMDD` when a facet newsletter can summarize recent movement. -4. Use `journal identity pulse` to compare broad narrative priorities with the search results. - -## Synthesize - -- Identify new mentions, rising themes, repeated unresolved issues, and fading priorities. -- Group related findings together instead of listing searches in order. -- Highlight what changed since the previous routine output if prior output exists. -- Separate durable patterns from one-off noise. -- Flag anything that appears to deserve deeper attention or follow-up. - -## Write - -Produce markdown with sections such as: - -- `## New Signals` -- `## Trends` -- `## Risks or Open Questions` -- `## What to Watch Next` - -Use bullets with enough context to be useful later. -Keep the output concise and analytical. -Do not dump raw search results unless a short quoted phrase is needed for clarity. diff --git a/solstone/talent/routines/templates/energy-audit.md b/solstone/talent/routines/templates/energy-audit.md deleted file mode 100644 index e55a75d43..000000000 --- a/solstone/talent/routines/templates/energy-audit.md +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "energy-audit", - "description": "Weekly reflection on where energy went — productive depth, meetings, context-switching, and drift.", - "default_cadence": "0 17 * * 5", - "default_timezone": "UTC", - "default_facets": [] -} - -You are preparing a weekly energy audit — a reflection on where the owner's time and attention actually went, versus where they intended it to go. - -## Gather - -1. Use `sol call activities list --source anticipated --day YYYYMMDD` for each of the past 7 days to map scheduled load. -2. Use `sol call journal search "" --day-from START --day-to END -n 30` to survey activity patterns. -3. Use `journal identity pulse` for the current state narrative. -4. Use `sol call journal news FACET --day YYYYMMDD` for representative days across active facets. - -## Synthesize - -- Map the week into blocks: deep work, meetings, reactive work (email/messaging), context-switching, and drift (time that went somewhere unintentional). -- Compare meeting-heavy days against productive-output days. Is there a pattern? -- Identify the longest unbroken focus blocks and what enabled them. -- Note context-switching patterns — rapid jumps between facets or activities that fragment attention. -- Look for drift: time that didn't clearly serve any active priority or intention. - -## Write - -Structure as a reflection: - -- `## Where Energy Went` — The week in broad strokes: how much deep work, how many meetings, how much reactive time -- `## Best Blocks` — The most productive stretches and what conditions enabled them -- `## Fragmentation` — Where context-switching or interruptions cost the most -- `## Drift` — Time that didn't serve stated priorities (not a judgment — just visibility) -- `## One Adjustment` — A single concrete suggestion for next week based on the patterns - -Keep the tone observational, not motivational. -Use calendar and activity evidence, not assumptions. -The owner knows what they meant to do — this shows them what they actually did. diff --git a/solstone/talent/routines/templates/meeting-prep.md b/solstone/talent/routines/templates/meeting-prep.md deleted file mode 100644 index f16907dbe..000000000 --- a/solstone/talent/routines/templates/meeting-prep.md +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "meeting-prep", - "description": "Prepare a concise briefing before an upcoming anticipated activity using participant and topic context.", - "default_cadence": {"type": "activity-anticipation", "offset_minutes": -30}, - "default_timezone": "UTC", - "default_facets": [] -} - -You are preparing for an upcoming meeting. - -The routine prompt already includes an `Upcoming Activity` section with the title, start time, participants, and description. Use that activity context as the anchor for all research and synthesis. - -## Gather - -1. Read the upcoming event details in the prompt carefully. -2. If you need broader context, call `sol call activities list --source anticipated --day $day_YYYYMMDD` to see the surrounding schedule. -3. For each listed participant, call `sol call entities intelligence PERSON --brief`. -4. Use `sol call journal search QUERY -n 10` to look for recent mentions of the meeting topic, project, or participants. -5. If a configured facet seems especially relevant, use `sol call journal news FACET --day $day_YYYYMMDD`. -6. Use `journal identity pulse` if it helps connect the meeting to current priorities or tensions. - -## Synthesize - -- Summarize who is involved and what matters about each participant. -- Identify recent context that is likely to come up. -- Note open loops, decisions pending, and useful reminders. -- Surface risks, unresolved questions, and preparation gaps. -- Keep the brief short enough to read right before the meeting. - -## Write - -Write markdown with sections such as: - -- `## Meeting` -- `## Participant Context` -- `## Likely Topics` -- `## Open Questions` -- `## Prep Notes` - -Use bullets and short sentences. -Do not repeat the full raw event block unless needed for clarity. -Focus on what will help right before the meeting starts. diff --git a/solstone/talent/routines/templates/monthly-patterns.md b/solstone/talent/routines/templates/monthly-patterns.md deleted file mode 100644 index 2d8c39b5e..000000000 --- a/solstone/talent/routines/templates/monthly-patterns.md +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "monthly-patterns", - "description": "Monthly analysis of recurring themes, focus shifts, and relationship activity over the past month.", - "default_cadence": "0 9 1 * *", - "default_timezone": "UTC", - "default_facets": [] -} - -You are analyzing the last month of journal activity for recurring patterns and meaningful shifts. - -Work at the month scale: look for durable changes in attention, habits, projects, and relationships. - -## Gather - -1. Use `sol call journal facets` to identify the facets in scope. -2. Use `sol call journal search "" --day-from START --day-to END -n 40` to survey the month across the configured facets. -3. Use `sol call journal news FACET --day YYYYMMDD` for representative weekly or recent snapshots when they help summarize a facet. -4. Use `sol call entities intelligence PERSON` for people who appear central to the month. -5. Use `sol call activities list --source anticipated --day YYYYMMDD` on representative days if scheduled load seems important. -6. Use `journal identity pulse` to compare month-long patterns against the current state narrative. - -## Synthesize - -- Identify recurring themes, repeated bottlenecks, and shifts in focus. -- Note whether energy moved toward or away from particular projects, people, or responsibilities. -- Highlight any relationship frequency changes that seem important. -- Compare early-month versus late-month signals when that reveals a trend. - -## Write - -Write markdown with sections such as: - -- `## Month at a Glance` -- `## Recurring Patterns` -- `## Focus Shifts` -- `## Relationship Signals` -- `## Questions for Next Month` - -Use concise bullets and short explanations. -Prefer pattern-level insight over day-by-day narration. diff --git a/solstone/talent/routines/templates/morning-briefing.md b/solstone/talent/routines/templates/morning-briefing.md deleted file mode 100644 index 3b40df24d..000000000 --- a/solstone/talent/routines/templates/morning-briefing.md +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "morning-briefing", - "description": "Daily morning digest of calendar, follow-ups, priorities, and relationship context.", - "default_cadence": "0 7 * * *", - "default_timezone": "UTC", - "default_facets": [] -} - -You are preparing a daily morning briefing for this routine run. - -This is not a conversation. Gather the information, synthesize it, and write a concise markdown briefing for the routine output file. - -## Gather - -1. Call `sol call journal facets` to see the active facets if you need broader context. -2. Call `sol call activities list --source anticipated --day $day_YYYYMMDD` to review today's scheduled items and participants. -3. Call `journal identity pulse` to capture current narrative, priorities, and needs-you items. -4. Call `sol call journal search "" -a followups -n 10` to find recent follow-up items. -5. For each person on today's calendar, call `sol call entities intelligence PERSON --brief`. -6. If a facet needs more detail, call `sol call journal news FACET --day $day_YYYYMMDD`. - -## Synthesize - -- Lead with today's calendar in chronological order. -- For each meeting, include attendees and one line of relationship or project context from entity intelligence. -- Surface the most important follow-ups and pulse items that should shape the day. -- Highlight any follow-ups or pulse items that need attention today. -- If there are no meetings, lead with the highest-priority actionable work. - -## Write - -Write a markdown briefing with short sections such as: - -- `## Today` -- `## Needs Attention` -- `## People Context` -- `## Optional Reading` - -Use bullets, not long paragraphs. -Omit empty sections entirely. -Keep the briefing scannable and practical. -Do not include greetings, sign-offs, or commentary about your process. diff --git a/solstone/talent/routines/templates/relationship-pulse.md b/solstone/talent/routines/templates/relationship-pulse.md deleted file mode 100644 index b4bf2f908..000000000 --- a/solstone/talent/routines/templates/relationship-pulse.md +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "relationship-pulse", - "description": "Review relationship health and identify people who need attention or follow-through.", - "default_cadence": "0 9 * * 1", - "default_timezone": "UTC", - "default_facets": [] -} - -You are reviewing relationship health across the routine's configured facets. - -Focus on people who matter operationally or personally, especially where contact, follow-through, or momentum has changed. - -## Gather - -1. Use `sol call journal facets` if you need to confirm the active facet set. -2. Use `sol call journal search "" --facet FACET -n 20` to identify frequently mentioned people or recent interactions in each relevant facet. -3. For each meaningful person, call `sol call entities intelligence PERSON`. -4. Use `sol call journal news FACET --day $day_YYYYMMDD` if a facet summary helps explain current context. -5. Use `journal identity pulse` for broad priorities that may affect relationship maintenance. - -## Synthesize - -- Identify strong, active relationships versus neglected or at-risk ones. -- Note recent interactions, open loops, and people who likely need a reply, check-in, or prep. -- Prioritize by importance and recency, not by raw mention count. -- Distinguish between work relationships, collaborators, and personal contacts where relevant. -- For the 2-3 most active relationships this week, note not just frequency but - quality signals: Are conversations getting deeper or more transactional? Is - initiative balanced or one-sided? Are there topics being avoided? -- End with one reflective observation: a relationship trend the owner might not - see from the inside. - -## Write - -Write markdown with sections such as: - -- `## Active Relationships` -- `## Needs Attention` -- `## Open Loops` -- `## Suggested Next Moves` -- `## Reflection` — One honest observation about a relationship pattern the data reveals - -Keep each person entry short and specific. -Use entity intelligence to ground your judgments. -Avoid generic advice; tie every recommendation to journal evidence. diff --git a/solstone/talent/routines/templates/weekly-review.md b/solstone/talent/routines/templates/weekly-review.md deleted file mode 100644 index ab4375415..000000000 --- a/solstone/talent/routines/templates/weekly-review.md +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "weekly-review", - "description": "Weekly reflection on themes, work completed, and planning signals from the last 7 days.", - "default_cadence": "0 18 * * 5", - "default_timezone": "UTC", - "default_facets": [] -} - -You are writing a weekly review covering the last 7 days. - -Gather evidence from the journal first, then synthesize a reflective but actionable markdown review. - -## Gather - -1. Use `sol call journal facets` to identify the facets in scope. -2. Use `sol call journal search "" --day-from $day_minus_7_YYYYMMDD --day-to $day_YYYYMMDD -n 25` to find notable entries and themes. -3. Use `sol call activities list --source anticipated --day YYYYMMDD` across the last 7 days to understand scheduled load and major time commitments. -4. Use `journal identity pulse` for the current state narrative. -5. Use `sol call journal news FACET --day YYYYMMDD` for any facet that needs a richer summary. - -## Synthesize - -- Identify the week's main themes and where attention actually went. -- Call out notable progress, stalled areas, and repeated friction. -- Note patterns in calendar density, follow-through, or context switching. -- Distinguish between signal and noise; do not produce a raw diary. -- End with 3-5 concrete priorities or questions for the coming week. - -## Write - -Structure the markdown with sections such as: - -- `## Week in Review` -- `## What Moved` -- `## Friction and Gaps` -- `## Next Week` - -Use bullets and short supporting sentences. -Anchor claims in the gathered evidence. -Keep the tone direct and reflective, not motivational. diff --git a/solstone/think/call.py b/solstone/think/call.py index d30c4c25e..7af26549e 100644 --- a/solstone/think/call.py +++ b/solstone/think/call.py @@ -107,7 +107,6 @@ call_app.add_typer(journal_app, name="journal") call_app.add_typer(ledger_app, name="ledger") call_app.add_typer(_moved_stub("navigate"), name="navigate") call_app.add_typer(profile_app, name="profile") -call_app.add_typer(_moved_stub("routines"), name="routines") call_app.add_typer(_moved_stub("identity"), name="identity") diff --git a/solstone/think/cogitate_policy.py b/solstone/think/cogitate_policy.py index b4c8280d1..71b21c02d 100644 --- a/solstone/think/cogitate_policy.py +++ b/solstone/think/cogitate_policy.py @@ -39,7 +39,7 @@ DETERMINISTIC_FAILURE_REASON_CODES = frozenset( } ) -_JOURNAL_COMMANDS = {"identity", "routines", "health", "talent"} +_JOURNAL_COMMANDS = {"identity", "health", "talent"} _SHELL_OPERATOR_CHARS = frozenset("();<>|&") _WRITE_TOOLS = {"write_file", "replace"} _READ_TOOLS = frozenset(COGITATE_READ_TOOL_NAMES) diff --git a/solstone/think/routines.py b/solstone/think/routines.py deleted file mode 100644 index 99a6b1d54..000000000 --- a/solstone/think/routines.py +++ /dev/null @@ -1,633 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""User-defined routines engine for the supervisor. - -Reads routine definitions from routines/config.json, evaluates cron -expressions each tick, and dispatches due routines as cogitate agents -via cortex. Output is written to routines/{routine-id}/{YYYYMMDD}.md. - -Runtime functions (init, check) are used by the supervisor. -""" - -from __future__ import annotations - -import json -import logging -import tempfile -import time -from datetime import datetime, timedelta, timezone -from datetime import datetime as real_datetime -from pathlib import Path -from typing import Any -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -from solstone.think.callosum import callosum_send -from solstone.think.cortex_client import cortex_request, wait_for_uses -from solstone.think.utils import get_journal - -logger = logging.getLogger(__name__) - -_config: dict[str, dict[str, Any]] = {} -_callosum: Any = None -_last_fired: dict[str, str] = {} # routine_id -> "YYYY-MM-DD HH:MM" of last fire -_fired_triggers: dict[str, dict[str, str]] = {} -# Crossover band for anticipation scan: load adjacent-day activity files when -# local_now is within this many minutes of midnight. Covers offset_minutes up to ~2h. -_ACTIVITY_ANTICIPATION_CROSSDAY_WINDOW_MINUTES = 120 -_logged_unknown_cadence: set[str] = set() - - -def _parse_cron_field(field: str, min_val: int, max_val: int) -> set[int]: - """Parse a single cron field into a set of valid integers.""" - if "," in field: - values: set[int] = set() - for part in field.split(","): - values.update(_parse_cron_field(part, min_val, max_val)) - return values - - if field == "*": - return set(range(min_val, max_val + 1)) - - if field.startswith("*/"): - step = int(field[2:]) - if step <= 0: - raise ValueError("Cron step must be > 0") - return set(range(min_val, max_val + 1, step)) - - if "/" in field: - range_part, step_part = field.split("/", 1) - step = int(step_part) - if step <= 0: - raise ValueError("Cron step must be > 0") - if "-" not in range_part: - raise ValueError(f"Invalid cron range step field: {field}") - start_str, end_str = range_part.split("-", 1) - start = int(start_str) - end = int(end_str) - if start > end: - raise ValueError(f"Invalid cron range: {field}") - if start < min_val or end > max_val: - raise ValueError(f"Cron value out of range: {field}") - return set(range(start, end + 1, step)) - - if "-" in field: - start_str, end_str = field.split("-", 1) - start = int(start_str) - end = int(end_str) - if start > end: - raise ValueError(f"Invalid cron range: {field}") - if start < min_val or end > max_val: - raise ValueError(f"Cron value out of range: {field}") - return set(range(start, end + 1)) - - value = int(field) - if value < min_val or value > max_val: - raise ValueError(f"Cron value out of range: {field}") - return {value} - - -def cron_matches(expression: str, dt: datetime) -> bool: - """Return whether a datetime matches a five-field cron expression.""" - fields = expression.split() - if len(fields) != 5: - raise ValueError("Cron expression must have exactly 5 fields") - - minute_set = _parse_cron_field(fields[0], 0, 59) - hour_set = _parse_cron_field(fields[1], 0, 23) - dom_set = _parse_cron_field(fields[2], 1, 31) - month_set = _parse_cron_field(fields[3], 1, 12) - dow_set = _parse_cron_field(fields[4], 0, 7) - if 7 in dow_set: - dow_set.remove(7) - dow_set.add(0) - - dow = dt.isoweekday() % 7 - return ( - dt.minute in minute_set - and dt.hour in hour_set - and dt.day in dom_set - and dt.month in month_set - and dow in dow_set - ) - - -def get_config() -> dict[str, dict[str, Any]]: - """Read routines/config.json.""" - config_path = Path(get_journal()) / "routines" / "config.json" - if not config_path.exists(): - return {} - - try: - with open(config_path, "r", encoding="utf-8") as f: - raw = json.load(f) - except (json.JSONDecodeError, OSError) as exc: - logger.warning("Failed to load routines config: %s", exc) - return {} - - if not isinstance(raw, dict): - logger.warning( - "routines/config.json must be a JSON object, got %s", type(raw).__name__ - ) - return {} - return raw - - -def save_config(config: dict[str, dict[str, Any]]) -> None: - """Persist routines/config.json atomically.""" - routines_dir = Path(get_journal()) / "routines" - routines_dir.mkdir(parents=True, exist_ok=True) - config_path = routines_dir / "config.json" - - fd, tmp_path = tempfile.mkstemp(dir=routines_dir, suffix=".tmp", prefix=".config_") - tmp_file = Path(tmp_path) - try: - with open(fd, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - tmp_file.replace(config_path) - except BaseException: - tmp_file.unlink(missing_ok=True) - raise - - -def _format_cadence_human(cadence: object) -> str: - """Format a cadence for human display in routine state.""" - return str(cadence) - - -def get_routine_state() -> list[dict[str, Any]]: - """Return routine summaries for pre-hook injection. - - Reads config from disk and output files. Does not use module-level - state or supervisor-only imports (cortex/callosum). - """ - config = get_config() - now_utc = datetime.now(timezone.utc) - result = [] - for routine in config.values(): - routine_id = routine.get("id") - if not routine_id: - continue - summary: dict[str, Any] = { - "name": routine.get("name", ""), - "cadence": _format_cadence_human(routine.get("cadence", "")), - "last_run": routine.get("last_run"), - "enabled": routine.get("enabled", False), - "paused_until": routine.get("resume_date"), - } - output_summary = None - last_run = routine.get("last_run") - if last_run: - try: - last_dt = real_datetime.fromisoformat(last_run) - if (now_utc - last_dt).total_seconds() < 43200: - output_dir = Path(get_journal()) / "routines" / routine_id - outputs = sorted(output_dir.glob("*.md")) - if outputs: - text = outputs[-1].read_text(encoding="utf-8").strip() - output_summary = text[:100] - except (ValueError, OSError): - pass - summary["output_summary"] = output_summary - result.append(summary) - return result - - -def init(callosum: Any) -> None: - """Initialize routines runtime state.""" - global _callosum, _config - _callosum = callosum - _config = get_config() - logger.info("Routines initialized with %d routine(s)", len(_config)) - - -def _log_health(routine_id: str, name: str, duration: int, outcome: str) -> None: - """Append a line to health/routines.log.""" - health_dir = Path(get_journal()) / "health" - health_dir.mkdir(parents=True, exist_ok=True) - health_path = health_dir / "routines.log" - ts = datetime.now(timezone.utc).isoformat() - with open(health_path, "a", encoding="utf-8") as f: - f.write( - f"{ts} routine={routine_id} name={name} duration={duration}s outcome={outcome}\n" - ) - - -def _render_upcoming_activity_block(activity: dict | None, facet: str) -> str: - """Render an upcoming activity context block for routine prompts.""" - if not activity: - return "" - - participation = activity.get("participation") or [] - if not isinstance(participation, list): - participation = [] - attendees = ", ".join( - str(entry.get("name") or "").strip() - for entry in participation - if isinstance(entry, dict) - and entry.get("role") == "attendee" - and str(entry.get("name") or "").strip() - ) - attendees = attendees or "(none listed)" - - title = str(activity.get("title") or "") - activity_type = str(activity.get("activity") or "(unknown)") - start = str(activity.get("start") or "(unknown)") - end = str(activity.get("end") or "(unknown)") - description = str(activity.get("description") or "(none)") - details = str(activity.get("details") or "(none)") - facet_display = str(facet or "(none)") - - return ( - "## Upcoming Activity\n\n" - f"- **Title:** {title}\n" - f"- **Type:** {activity_type}\n" - f"- **Facet:** {facet_display}\n" - f"- **Start:** {start}\n" - f"- **End:** {end}\n" - f"- **Description:** {description}\n" - f"- **Details:** {details}\n" - f"- **Attendees:** {attendees}\n\n" - ) - - -def resolve_routine_access_tier(routine: dict) -> Any: - """Return the routine's declared access_tier, defaulting to 'normal' when absent. - - Read-only. Returns the raw declared value — which may be malformed (non-string, - empty, unknown) — so the caller's guard can fail loudly rather than silently - coercing a bad declaration down to 'normal'. A present-but-null value resolves - to None (a guard failure), NOT to the absent default. - """ - return routine.get("access_tier", "normal") - - -def _unsupported_tier_reason(tier: Any) -> str | None: - """Reason a routine may not run, or None when the tier is exactly 'normal'. - - Routines are capped at 'normal'. Anything else — a higher declared tier - (system-read / outbound / code-agent) or a malformed value (non-string, empty - string, list, unknown string) — yields a human-readable failure reason. - """ - if tier == "normal": - return None - return f"unsupported access_tier {tier!r}; routines are capped at 'normal'" - - -def _tier_log_token(tier: Any) -> str: - """A whitespace-free token naming the declared tier for the health-log outcome.""" - if isinstance(tier, str) and tier and not any(c.isspace() for c in tier): - return tier - return "malformed" - - -def _tier_failure_marker(name: str, reason: str) -> str: - """Self-evident output-file notice written when the access-tier guard blocks a run. - - Worded as an unmistakable non-run so a later run that reads this file as - 'Previous output:' for continuity will not treat it as task results. - """ - return ( - "# Routine did not run\n\n" - f"Routine **{name}** was not executed: {reason}.\n\n" - "Routines run at the `normal` access tier only. Remove the `access_tier` " - 'field (or set it to `"normal"`) in routines/config.json to let this ' - "routine run.\n" - ) - - -def _run_routine(routine: dict, trigger_context: dict | None = None) -> str | None: - """Execute a single routine and persist its outcome. - - Returns a human-readable failure reason ONLY when the routine is blocked - before dispatch by the access-tier guard, so the manual `run` command can - surface it to the operator's terminal. Returns None in every dispatched case - (including downstream success/error/timeout); unattended callers (the - scheduler) ignore the return value. - """ - routine_id = str(routine.get("id", "unknown")) - name = str(routine.get("name", routine_id)) - start_time = time.monotonic() - output_path: Path | None = None - - try: - journal = Path(get_journal()) - output_dir = journal / "routines" / routine_id - output_dir.mkdir(parents=True, exist_ok=True) - - now_utc = datetime.now(timezone.utc) - output_path = output_dir / f"{now_utc.strftime('%Y%m%d')}.md" - if output_path.exists(): - output_path = output_dir / f"{now_utc.strftime('%Y%m%d-%H%M%S')}.md" - - # Access-tier guard: routines are capped at 'normal'. Over-declaration or a - # malformed value fails loudly here — before any dispatch side effect — and - # is recorded on every surface the owner might check. - tier = resolve_routine_access_tier(routine) - reason = _unsupported_tier_reason(tier) - if reason is not None: - duration = int(time.monotonic() - start_time) - logger.error("Routine %s blocked before dispatch: %s", routine_id, reason) - output_path.write_text(_tier_failure_marker(name, reason), encoding="utf-8") - _log_health( - routine_id, - name, - duration, - f"error-access-tier-{_tier_log_token(tier)}", - ) - callosum_send( - "routines", - "complete", - routine_id=routine_id, - name=name, - outcome="error", - reason=reason, - output_path=str(output_path), - duration_s=duration, - ) - return reason - - instruction = str(routine.get("instruction", "")) - facets = routine.get("facets") or [] - _template = routine.get("template") - _notify = bool(routine.get("notify", False)) - - previous_outputs = sorted(output_dir.glob("*.md")) - prev_output_path = str(previous_outputs[-1]) if previous_outputs else None - - facets_line = f"**Facets:** {', '.join(facets)}" if facets else "" - previous_line = ( - f"**Previous output:** {prev_output_path}" if prev_output_path else "" - ) - cadence_raw = routine.get("cadence") - if isinstance(cadence_raw, dict): - cadence_display = str(cadence_raw.get("type", "")) - else: - cadence_display = str(cadence_raw or "") - - upcoming_block = "" - if trigger_context and "activity" in trigger_context: - upcoming_block = _render_upcoming_activity_block( - trigger_context["activity"], trigger_context.get("facet", "") - ) - - prompt = ( - f"## Routine: {name}\n\n" - f"**Instruction:** {instruction}\n\n" - f"**Cadence:** {cadence_display}\n" - f"{facets_line}\n" - f"{previous_line}\n\n" - f"{upcoming_block}" - "Execute this routine now. Write your output as concise, actionable markdown.\n" - ) - - callosum_send("routines", "started", routine_id=routine_id, name=name) - use_id = cortex_request( - prompt=prompt, - name="routine", - config={"output_path": str(output_path), "output": "md"}, - ) - - if use_id is None: - duration = int(time.monotonic() - start_time) - logger.error("Failed to start routine %s", routine_id) - _log_health(routine_id, name, duration, "error") - callosum_send( - "routines", - "complete", - routine_id=routine_id, - name=name, - outcome="error", - output_path=str(output_path), - duration_s=duration, - ) - return None - - completed, timed_out = wait_for_uses([use_id], timeout=600) - if use_id in timed_out: - outcome = "timeout" - else: - end_state = completed.get(use_id, "error") - outcome = "success" if end_state == "finish" else "error" - - duration = int(time.monotonic() - start_time) - routine["last_run"] = datetime.now(timezone.utc).isoformat() - _config[routine_id] = routine - save_config(_config) - - callosum_send( - "routines", - "complete", - routine_id=routine_id, - name=name, - outcome=outcome, - output_path=str(output_path), - duration_s=duration, - ) - _log_health(routine_id, name, duration, outcome) - return None - except Exception as exc: - duration = int(time.monotonic() - start_time) - logger.exception("Routine %s failed: %s", routine_id, exc) - try: - _log_health(routine_id, name, duration, "error") - except Exception: - logger.exception("Failed to write routines health log for %s", routine_id) - try: - callosum_send( - "routines", - "complete", - routine_id=routine_id, - name=name, - outcome="error", - output_path=str(output_path) if output_path else "", - duration_s=duration, - ) - except Exception: - logger.exception("Failed to emit routine completion for %s", routine_id) - return None - - -def _activity_anticipation_candidate_days( - local_now: datetime, window_minutes: int -) -> list[str]: - """Return chronological YYYYMMDD strings to scan for anticipation triggers. - - Always includes today. Adds yesterday when local_now falls within the first - `window_minutes` after midnight, and tomorrow when local_now falls within the - last `window_minutes` before midnight. - """ - minutes_since_midnight = local_now.hour * 60 + local_now.minute - days: list[str] = [] - if minutes_since_midnight < window_minutes: - days.append((local_now - timedelta(days=1)).strftime("%Y%m%d")) - days.append(local_now.strftime("%Y%m%d")) - if minutes_since_midnight >= 24 * 60 - window_minutes: - days.append((local_now + timedelta(days=1)).strftime("%Y%m%d")) - return days - - -def _prune_fired_triggers(*, now_utc: datetime) -> None: - """Drop in-memory activity trigger dedupe entries older than two days.""" - from datetime import timedelta - - cutoff = now_utc - timedelta(days=2) - for routine_id, fired_for_routine in list(_fired_triggers.items()): - for activity_id, fired_at in list(fired_for_routine.items()): - try: - fired_dt = real_datetime.fromisoformat(fired_at) - except ValueError: - del fired_for_routine[activity_id] - continue - if fired_dt < cutoff: - del fired_for_routine[activity_id] - if not fired_for_routine: - del _fired_triggers[routine_id] - - -def check() -> None: - """Reload config and run any due routines.""" - global _config - _config = get_config() - _prune_fired_triggers(now_utc=datetime.now(timezone.utc)) - - config_changed = False - for routine in _config.values(): - resume_date = routine.get("resume_date") - if not resume_date or routine.get("enabled"): - continue - routine_id = routine.get("id") - if not routine_id: - continue - tz = routine.get("timezone") or "UTC" - try: - local_today = ( - datetime.now(timezone.utc).astimezone(ZoneInfo(tz)).strftime("%Y-%m-%d") - ) - except ZoneInfoNotFoundError: - continue - if resume_date <= local_today: - routine["enabled"] = True - routine.pop("resume_date", None) - config_changed = True - name = routine.get("name", routine_id) - _log_health(routine_id, name, 0, "auto-resumed") - logger.info("Auto-resumed routine %s (%s)", routine_id, name) - if config_changed: - save_config(_config) - - now_utc = datetime.now(timezone.utc) - for routine in _config.values(): - if not routine.get("enabled"): - continue - - routine_id = routine.get("id") - if not routine_id: - continue - - tz = routine.get("timezone") or "UTC" - try: - local_now = now_utc.astimezone(ZoneInfo(tz)) - except ZoneInfoNotFoundError: - logger.warning( - "Routine %s has invalid timezone %r, skipping", routine_id, tz - ) - continue - cadence = routine.get("cadence") - - if isinstance(cadence, str): - minute_key = local_now.strftime("%Y-%m-%d %H:%M") - if _last_fired.get(routine_id) == minute_key: - continue - if cron_matches(cadence, local_now): - _last_fired[routine_id] = minute_key - _run_routine(routine) - # Keep this cadence-object dispatch in sync with think.tools.routines._validate_routine_cadence(). - elif ( - isinstance(cadence, dict) and cadence.get("type") == "activity-anticipation" - ): - from datetime import timedelta - - from solstone.think.activities import load_activity_records - from solstone.think.facets import get_facets - - offset_raw = cadence.get("offset_minutes", 0) - try: - offset_minutes = int(offset_raw) - except (TypeError, ValueError): - logger.warning( - "Routine %s has invalid offset_minutes %r, skipping", - routine_id, - offset_raw, - ) - continue - - candidate_days = _activity_anticipation_candidate_days( - local_now, _ACTIVITY_ANTICIPATION_CROSSDAY_WINDOW_MINUTES - ) - fired_for_routine = _fired_triggers.setdefault(routine_id, {}) - - for facet_name in get_facets().keys(): - for day_str in candidate_days: - try: - records = load_activity_records(facet_name, day_str) - except Exception: - logger.warning( - "Failed loading activities for facet %s on %s", - facet_name, - day_str, - exc_info=True, - ) - continue - - for record in records: - if record.get("source") != "anticipated": - continue - activity_id = record.get("id") - if not activity_id or activity_id in fired_for_routine: - continue - start_str = record.get("start") - if not start_str: - continue - try: - time_parts = [int(x) for x in str(start_str).split(":")] - except (ValueError, AttributeError): - continue - if len(time_parts) == 2: - h, m = time_parts - s = 0 - elif len(time_parts) == 3: - h, m, s = time_parts - else: - continue - start_dt = datetime( - int(day_str[:4]), - int(day_str[4:6]), - int(day_str[6:8]), - h, - m, - s, - tzinfo=local_now.tzinfo, - ) - trigger_dt = start_dt + timedelta(minutes=offset_minutes) - if abs((local_now - trigger_dt).total_seconds()) > 60: - continue - fired_for_routine[activity_id] = now_utc.isoformat() - _run_routine( - routine, - trigger_context={"activity": record, "facet": facet_name}, - ) - elif isinstance(cadence, dict): - cadence_type = str(cadence.get("type", "unknown")) - if routine_id not in _logged_unknown_cadence: - logger.info( - "Routine %s has unsupported cadence type %r, skipping", - routine_id, - cadence_type, - ) - _logged_unknown_cadence.add(routine_id) - - -def save_state() -> None: - """Persist routines state.""" - save_config(_config) diff --git a/solstone/think/sol_cli.py b/solstone/think/sol_cli.py index 5d60c1cef..5f3eec4d4 100644 --- a/solstone/think/sol_cli.py +++ b/solstone/think/sol_cli.py @@ -116,7 +116,6 @@ COMMANDS: dict[str, Command] = { "spl": Command("solstone.think.spl", "service"), "call": Command("solstone.think.call", "access"), "navigate": Command("solstone.think.tools.navigate", "service"), - "routines": Command("solstone.think.tools.routines", "service"), "identity": Command("solstone.think.tools.sol", "service"), "engage": Command("solstone.think.engage", "service"), "chat": Command("solstone.think.chat_cli", "access"), diff --git a/solstone/think/supervisor.py b/solstone/think/supervisor.py index ad88ab990..1cfa21ebb 100644 --- a/solstone/think/supervisor.py +++ b/solstone/think/supervisor.py @@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, NoReturn import psutil -from solstone.think import maintenance, routines, scheduler +from solstone.think import maintenance, scheduler from solstone.think.app_supervised import FLAG, is_app_supervised, resolve_parent_fd from solstone.think.callosum import CallosumConnection, CallosumServer from solstone.think.maint import run_pending_tasks @@ -2298,7 +2298,6 @@ async def supervise( # Check periodic task schedules (non-blocking, submits via callosum) if schedule: scheduler.check() - routines.check() # Sleep 1 second before next iteration (responsive to shutdown) await asyncio.sleep(1) @@ -2649,7 +2648,6 @@ def main() -> None: cmd_name, seconds, ) - routines.init(_supervisor_callosum) if _task_queue: _task_queue.set_ready() @@ -2709,12 +2707,6 @@ def main() -> None: for managed in reversed(procs): _stop_process(managed, timeout_cap=child_stop_timeout) - if schedule_enabled: - try: - routines.save_state() - except Exception as exc: - logging.warning("Failed to save routines state on shutdown: %s", exc) - # Disconnect supervisor's Callosum connection if _supervisor_callosum: _supervisor_callosum.stop() diff --git a/solstone/think/talent_cli.py b/solstone/think/talent_cli.py index 026cb7750..37f321df5 100644 --- a/solstone/think/talent_cli.py +++ b/solstone/think/talent_cli.py @@ -472,7 +472,7 @@ def _scan_command_examples(body: str, *, cap: int = 6) -> list[str]: """Scan prompt body text for command examples.""" pattern = re.compile( r"`(?P(?:sol\s+call\s+[^\n`]+|journal\s+" - r"(?:identity|routines|health|talent)\b[^\n`]*))`" + r"(?:identity|health|talent)\b[^\n`]*))`" ) seen: set[str] = set() result: list[str] = [] diff --git a/solstone/think/tools/routines.py b/solstone/think/tools/routines.py deleted file mode 100644 index 169310e8c..000000000 --- a/solstone/think/tools/routines.py +++ /dev/null @@ -1,448 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for managing user-defined routines. - -Top-level ``journal routines`` command. -""" - -import json -import sys -import uuid -from datetime import datetime -from datetime import timezone as dt_tz -from pathlib import Path -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError - -import frontmatter -import typer - -from solstone.think.routines import _run_routine, cron_matches, get_config, save_config -from solstone.think.utils import get_journal, require_solstone - -app = typer.Typer(help="Manage custom routines.") - - -@app.callback() -def _require_up() -> None: - require_solstone() - - -def _resolve_id(config: dict[str, dict], prefix: str) -> str: - """Resolve a routine by UUID prefix or exact name (case-insensitive).""" - matches = sorted( - rid for rid in config if not rid.startswith("_") and rid.startswith(prefix) - ) - if len(matches) == 1: - return matches[0] - if len(matches) > 1: - typer.echo(f"Error: routine id '{prefix}' is ambiguous.", err=True) - raise typer.Exit(1) - - lower = prefix.lower() - name_matches = sorted( - rid - for rid, routine in config.items() - if routine.get("id") and routine.get("name", "").lower() == lower - ) - if not name_matches: - typer.echo(f"Error: routine '{prefix}' not found.", err=True) - raise typer.Exit(1) - if len(name_matches) > 1: - typer.echo(f"Error: routine name '{prefix}' is ambiguous.", err=True) - raise typer.Exit(1) - return name_matches[0] - - -def _format_last_run(value: str | None) -> str: - if not value: - return "never" - try: - return datetime.fromisoformat(value).strftime("%Y-%m-%d %H:%M") - except ValueError: - return value - - -def _validate_timezone(name: str) -> None: - try: - ZoneInfo(name) - except ZoneInfoNotFoundError: - typer.echo(f"Error: invalid timezone: {name}", err=True) - raise typer.Exit(1) - - -def _parse_enabled(value: str) -> bool: - """Parse a CLI boolean value for routine enablement.""" - normalized = value.strip().lower() - if normalized in {"true", "1", "yes", "on"}: - return True - if normalized in {"false", "0", "no", "off"}: - return False - typer.echo("Error: enabled must be true or false.", err=True) - raise typer.Exit(1) - - -def _templates_dir() -> Path: - """Resolve the routines templates directory (package-relative).""" - return Path(__file__).resolve().parents[2] / "talent" / "routines" / "templates" - - -def _load_template(name: str) -> tuple[dict, str]: - """Load a template by name. Returns (metadata, instruction_body).""" - path = _templates_dir() / f"{name}.md" - if not path.is_file(): - typer.echo(f"Error: template '{name}' not found.", err=True) - raise typer.Exit(1) - post = frontmatter.load(path) - return dict(post.metadata), post.content.strip() - - -def _format_cadence(cadence: object) -> str: - """Format a cadence value for display.""" - return str(cadence) - - -def _validate_routine_cadence(cadence: object) -> None: - """Validate a cadence value accepted by routine config.""" - if isinstance(cadence, str): - try: - cron_matches(cadence, datetime.now()) - except ValueError as exc: - typer.echo(f"Error: invalid cadence: {exc}", err=True) - raise typer.Exit(1) - return - - # Keep this cadence-object validation in sync with think.routines.check(). - if isinstance(cadence, dict): - if "type" not in cadence: - typer.echo("Error: invalid cadence: missing 'type' field", err=True) - raise typer.Exit(1) - - cadence_type = cadence["type"] - if not isinstance(cadence_type, str): - typer.echo("Error: invalid cadence: 'type' must be a string", err=True) - raise typer.Exit(1) - - if cadence_type == "activity-anticipation": - if "offset_minutes" in cadence: - try: - int(cadence["offset_minutes"]) - except (TypeError, ValueError): - typer.echo( - "Error: invalid cadence: offset_minutes must be an integer", - err=True, - ) - raise typer.Exit(1) - return - - typer.echo( - f"Error: invalid cadence: unsupported cadence type {cadence_type!r}", - err=True, - ) - raise typer.Exit(1) - - typer.echo( - "Error: invalid cadence: must be a cron string or cadence object", err=True - ) - raise typer.Exit(1) - - -@app.command("list") -def list_routines() -> None: - """List all routines.""" - config = get_config() - routines = {k: v for k, v in config.items() if v.get("id")} - if not routines: - typer.echo("No routines configured.") - return - - for routine in routines.values(): - routine_id = routine.get("id", "") - enabled_marker = "on" if routine.get("enabled") else "off" - resume_date = routine.get("resume_date") - if not routine.get("enabled") and resume_date: - enabled_marker = f"off (resumes {resume_date})" - cadence_display = _format_cadence(routine.get("cadence", "")) - last_run_display = _format_last_run(routine.get("last_run")) - name = routine.get("name", "") - typer.echo( - f"{routine_id[:8]} {enabled_marker:<25} {cadence_display:<20} {last_run_display:<20} {name}" - ) - - -@app.command() -def templates() -> None: - """List available routine templates.""" - tpl_dir = _templates_dir() - if not tpl_dir.is_dir(): - typer.echo("No templates directory found.") - return - found = False - for path in sorted(tpl_dir.glob("*.md")): - post = frontmatter.load(path) - desc = post.metadata.get("description", "") - typer.echo(f"{path.stem:<25} {desc}") - found = True - if not found: - typer.echo("No templates found.") - - -@app.command() -def create( - name: str = typer.Option(None, help="Routine name"), - instruction: str = typer.Option(None, help="Natural-language instruction"), - cadence: str = typer.Option(None, help="Cron expression (5-field)"), - tz: str = typer.Option("", "--timezone", help="IANA timezone"), - facets: str = typer.Option("", help="Comma-separated facet names"), - template: str = typer.Option("", help="Template name"), -) -> None: - """Create a routine.""" - metadata: dict = {} - template_body = "" - if template: - metadata, template_body = _load_template(template) - name = name or metadata.get("name", template) - instruction = instruction or template_body - if cadence is None: - cadence = metadata.get("default_cadence") - if not tz: - tz = str(metadata.get("default_timezone", "UTC")) - if not facets: - default_facets = metadata.get("default_facets", []) - if isinstance(default_facets, list): - facets = ",".join(str(facet) for facet in default_facets) - - if name is None: - typer.echo("Error: routine name is required.", err=True) - raise typer.Exit(1) - if instruction is None: - typer.echo("Error: instruction is required.", err=True) - raise typer.Exit(1) - if cadence is None: - typer.echo("Error: cadence is required.", err=True) - raise typer.Exit(1) - - _validate_routine_cadence(cadence) - if not tz: - tz = "UTC" - _validate_timezone(tz) - - routine_id = str(uuid.uuid4()) - routine = { - "id": routine_id, - "name": name, - "instruction": instruction, - "cadence": cadence, - "timezone": tz, - "facets": [f.strip() for f in facets.split(",") if f.strip()], - "enabled": True, - "created": datetime.now(dt_tz.utc).isoformat(), - "last_run": None, - "template": template or None, - "notify": False, - } - - config = get_config() - config[routine_id] = routine - save_config(config) - typer.echo(f'Created routine {routine_id[:8]} "{name}"') - - -@app.command() -def edit( - routine_id: str = typer.Argument(help="Routine ID (or prefix)"), - name: str | None = typer.Option(None, help="New name"), - instruction: str | None = typer.Option(None, help="New instruction"), - cadence: str | None = typer.Option(None, help="New cron expression"), - tz: str | None = typer.Option(None, "--timezone", help="New timezone"), - enabled: str | None = typer.Option(None, help="Enable or disable"), - resume_date: str | None = typer.Option( - None, "--resume-date", help="ISO date (YYYY-MM-DD) to auto-resume" - ), - facets: str | None = typer.Option(None, help="Comma-separated facet names"), - template: str | None = typer.Option(None, help="Template name"), -) -> None: - """Edit a routine.""" - config = get_config() - full_id = _resolve_id(config, routine_id) - routine = config[full_id] - - if cadence is not None: - try: - cron_matches(cadence, datetime.now()) - except ValueError as exc: - typer.echo(f"Error: invalid cadence: {exc}", err=True) - raise typer.Exit(1) - routine["cadence"] = cadence - if name is not None: - routine["name"] = name - if instruction is not None: - routine["instruction"] = instruction - if tz is not None: - _validate_timezone(tz) - routine["timezone"] = tz - enabled_value: bool | None = None - if enabled is not None: - enabled_value = _parse_enabled(enabled) - routine["enabled"] = enabled_value - if enabled_value is True: - routine.pop("resume_date", None) - if resume_date is not None: - if resume_date == "": - routine.pop("resume_date", None) - else: - try: - datetime.strptime(resume_date, "%Y-%m-%d") - except ValueError: - typer.echo("Error: resume-date must be YYYY-MM-DD format.", err=True) - raise typer.Exit(1) - routine["resume_date"] = resume_date - if facets is not None: - routine["facets"] = [f.strip() for f in facets.split(",") if f.strip()] - if template is not None: - routine["template"] = template or None - - config[full_id] = routine - save_config(config) - typer.echo(f'Updated routine {full_id[:8]} "{routine.get("name", "")}"') - - -@app.command() -def delete(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: - """Delete a routine.""" - config = get_config() - full_id = _resolve_id(config, routine_id) - routine = config.pop(full_id) - - template_name = routine.get("template") - if template_name: - meta = config.get("_meta", {}) - suggestions = meta.get("suggestions", {}) - entry = suggestions.get(template_name) - if entry and entry.get("response") == "accepted": - entry["trigger_count"] = 0 - entry["first_trigger"] = None - entry["last_trigger"] = None - entry["trigger_data"] = {} - entry["response"] = None - entry["suggested"] = False - - save_config(config) - typer.echo(f'Deleted routine {full_id[:8]} "{routine.get("name", "")}"') - - -@app.command() -def run(routine_id: str = typer.Argument(help="Routine ID (or prefix)")) -> None: - """Run a routine immediately.""" - config = get_config() - full_id = _resolve_id(config, routine_id) - routine = config[full_id] - typer.echo(f'Running routine "{routine.get("name", "")}"...') - reason = _run_routine(routine) - if reason is not None: - typer.echo(f"Routine not run: {reason}", err=True) - raise typer.Exit(1) - typer.echo("Done.") - - -@app.command() -def output( - routine_id: str = typer.Argument(help="Routine ID (or prefix)"), - date: str | None = typer.Option(None, help="Date (YYYY-MM-DD) to show output for"), -) -> None: - """Print routine output (most recent, or for a specific date).""" - config = get_config() - full_id = _resolve_id(config, routine_id) - output_dir = Path(get_journal()) / "routines" / full_id - if not output_dir.exists(): - typer.echo("No output yet.") - return - if date is not None: - date_prefix = date.replace("-", "") - matches = sorted( - output_dir.glob(f"{date_prefix}*.md"), - key=lambda path: (len(path.stem), path.stem), - ) - if not matches: - typer.echo("No output for that date.") - return - sys.stdout.write(matches[-1].read_text(encoding="utf-8")) - else: - outputs = sorted(output_dir.glob("*.md"), reverse=True) - if not outputs: - typer.echo("No output yet.") - return - sys.stdout.write(outputs[0].read_text(encoding="utf-8")) - - -@app.command() -def suggestions( - enable: bool | None = typer.Option( - None, "--enable/--disable", help="Toggle suggestions" - ), -) -> None: - """Manage routine suggestions.""" - config = get_config() - meta = config.setdefault("_meta", {}) - if enable is not None: - meta["suggestions_enabled"] = enable - save_config(config) - state = "enabled" if enable else "disabled" - typer.echo(f"Routine suggestions {state}.") - else: - current = meta.get("suggestions_enabled", True) - state = "enabled" if current else "disabled" - typer.echo(f"Routine suggestions are {state}.") - - -@app.command("suggest-respond") -def suggest_respond( - template: str = typer.Argument(help="Template name"), - accepted: bool = typer.Option(False, "--accepted", help="Accept suggestion"), - declined: bool = typer.Option(False, "--declined", help="Decline suggestion"), -) -> None: - """Record response to a routine suggestion.""" - if accepted == declined: - typer.echo( - "Error: exactly one of --accepted or --declined is required.", err=True - ) - raise typer.Exit(code=1) - - config = get_config() - meta = config.setdefault("_meta", {}) - suggestions = meta.get("suggestions", {}) - - if template not in suggestions: - typer.echo(f"Error: no suggestion state for template '{template}'.", err=True) - raise typer.Exit(code=1) - - from datetime import date - - today = date.today().isoformat() - entry = suggestions[template] - - if accepted: - entry["response"] = "accepted" - else: - entry["response"] = "declined" - - entry["suggested"] = True - entry["last_suggestion_date"] = today - meta["last_suggestion_date"] = today - - save_config(config) - action = "accepted" if accepted else "declined" - typer.echo(f"Suggestion for '{template}' {action}.") - - -@app.command("suggest-state") -def suggest_state() -> None: - """Show suggestion state for all templates.""" - config = get_config() - meta = config.get("_meta", {}) - suggestions = meta.get("suggestions", {}) - typer.echo(json.dumps(suggestions, indent=2)) - - -def main() -> None: - app() diff --git a/tests/baselines/api/settings/providers.json b/tests/baselines/api/settings/providers.json index 3567d1445..d14f146f1 100644 --- a/tests/baselines/api/settings/providers.json +++ b/tests/baselines/api/settings/providers.json @@ -367,14 +367,6 @@ "tier": 2, "type": "cogitate" }, - "talent.system.routine": { - "disabled": false, - "group": "Think", - "label": "Routine", - "schedule": "none", - "tier": 2, - "type": "cogitate" - }, "talent.system.schedule": { "disabled": false, "group": "Think", diff --git a/tests/baselines/api/sol/preview.json b/tests/baselines/api/sol/preview.json index f0f49f739..fdb58c5bf 100644 --- a/tests/baselines/api/sol/preview.json +++ b/tests/baselines/api/sol/preview.json @@ -1,5 +1,5 @@ { - "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Who You Are\n\nYou are sol, responding to Tester. The latest user message in the conversation below is what you must answer. Earlier messages are background context, not the current question.\n\nYou are this owner's local agent — not Google, OpenAI, Anthropic, or a generic chatbot. You have no tools in this step; you respond directly from the context provided.\n\n$active_talents\n\n$active_routines\n\n$routine_suggestion\n\n$trigger_context\n\n## How To Respond\n\n- **Default to a direct answer.** Most replies are short and direct, drawn from identity and recent chat. No dispatch.\n- **Match the owner's tone:** direct and brief for simple replies; warm when they're sharing something difficult; analytical when they need synthesis; challenging only when a pattern is worth naming.\n- **Don't fabricate.** If answering needs a journal fact you don't have on hand, dispatch `read` to find it rather than inventing.\n- **Routine suggestions** (if any are in context) go once at the end, never on machine-driven follow-ups.\n- **Don't mention internal systems, hooks, or prompt assembly.**\n\n## When To Dispatch A Talent\n\nDispatching is the exception, not the rule. **First ask: can I answer this from\nwhat I already have?** If yes, just answer. Dispatch only when the answer needs\na capability you lack — and pick the one that matches the *verb* of the request:\n\n- `read` — **find or understand something in the journal.** A past\n conversation, a name, a quote, a file, a memory; or synthesis across time,\n relationships, or themes. This is the default dispatch — most lookups and all\n reflection go here. Preserve concrete hints (relative date/time, place, named\n people, quoted phrases) in the task. A brief \"let me check the journal\" bridge\n is fine; the owner's history is their own local journal — never claim it's\n inaccessible. Lookup answers preserve provenance: name the transcript, entry,\n or file evidence, or say it's thin — never synthesize a confident answer from\n a tool's error text.\n- `exec` — **do or change something.** Edit an entity, adjust an activity,\n create or run a routine, set the journal name/owner. Dispatch only when the\n owner clearly wants an action taken, and pass the specific change in the task.\n- `support` — **sol pbc help.** A bug report, a help request, feedback, or\n checking a ticket. The support talent files and monitors with sol pbc under\n the owner's consent.\n\n**Do NOT dispatch for:** greetings, thanks, acknowledgements, brief follow-ups,\nquestions about your role/capabilities, or generic \"what's up\" queries that need\nno new work.\n\nWhen dispatching, set `talent_request.context` to a compact JSON-encoded string of hints (e.g., `\"{\\\"person\\\":\\\"Adrian\\\"}\"`), or `null` when there are no hints. Never emit a raw JSON object.\n\n## Stop-And-Report Contract\n\nWhen this turn is a `talent_finished` or `talent_errored` follow-up (the latest message will say `[internal follow-up: talent ... finished ...]`):\n\n- **Set `talent_request: null`.** Do not dispatch another talent.\n- **Synthesize the result for the owner.** Use the talent's summary/reason to write the actual owner-facing reply, preserving provenance when this was a lookup.\n- **The previous turn already wrote a \"let me check...\" bridge.** Now is the time to deliver the answer or report the failure.\n\n## JSON Output Contract\n\nReturn exactly one JSON object matching `chat.schema.json`:\n\n- `message`: The owner-facing reply, written naturally. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: One concise internal sentence explaining your choice. No long reasoning dumps.\n- `talent_request`: `null` unless dispatching (rare). When dispatching, include `target` (`read`, `exec`, or `support`), `task` (the specific work), and `context` (compact JSON-encoded string of hints, or `null`).\n\nReturn JSON only.", + "full_prompt": "## Instructions\n\n## Available Facets\n\n- **Capulet Industries** (`capulet`)\n Capulet Industries enterprise division\n - **Capulet Industries Entities**: Tybalt Capulet; Juliet Capulet; Paris Duke; Nurse Angela; Capulet Industries\n - **Capulet Industries Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Empty Entities Test** (`empty-entities`)\n - **Empty Entities Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Full Featured Facet** (`full-featured`)\n A facet for testing all features\n - **Full Featured Facet Entities**: First test entity; Second test entity; Third test entity with description\n - **Full Featured Facet Activities**: Meetings; Coding; Custom Activity; Email; Messaging\n\n- **Minimal Facet** (`minimal-facet`)\n - **Minimal Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Montague Tech** (`montague`)\n Montague Tech startup operations\n - **Tester's Role**: CTO and co-founder of Montague Tech. Visionary full-stack engineer.\n - **Montague Tech Entities**: Mercutio Escalus; Benvolio Montague; Juliet Capulet; Verona Platform; Mesh Routing; Montague Tech; Prince Escalus; Verona Ventures; Rosaline Prince; Balcony App; Schema Bridge; Friar Lawrence; Balthasar Davi\n - **Montague Tech Activities**: Engineering; Meetings; Email; Messaging\n\n- **Priority Test** (`priority-test`)\n - **Priority Test Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Test Facet** (`test-facet`)\n A test facet for validating functionality\n - **Test Facet Entities**: John Smith; Acme Corp; API Optimization; Bob Wilson; Dashboard Redesign; Docker; Jane Doe; PostgreSQL; Tech Solutions Inc; Visual Studio Code\n - **Test Facet Activities**:\n - Meetings\n - call\n - deadline\n - appointment\n - event\n - travel\n - reminder\n - errand\n - celebration\n - doctor appointment\n - Coding\n - Browsing\n - Email\n - Messaging\n - AI Conversation\n - _and 10 more activities_\n\n- **Verona** (`verona`)\n Cross-company Verona Platform collaboration\n - **Tester's Role**: Co-lead of the Verona Platform joint venture from Montague Tech.\n - **Verona Entities**: Friar Lawrence; Juliet Capulet; Balcony App; Verona Platform\n - **Verona Activities**: Engineering; Meetings; Design Review; Email; Messaging\n\n## Who You Are\n\nYou are sol, responding to Tester. The latest user message in the conversation below is what you must answer. Earlier messages are background context, not the current question.\n\nYou are this owner's local agent — not Google, OpenAI, Anthropic, or a generic chatbot. You have no tools in this step; you respond directly from the context provided.\n\n$active_talents\n\n$trigger_context\n\n## How To Respond\n\n- **Default to a direct answer.** Most replies are short and direct, drawn from identity and recent chat. No dispatch.\n- **Match the owner's tone:** direct and brief for simple replies; warm when they're sharing something difficult; analytical when they need synthesis; challenging only when a pattern is worth naming.\n- **Don't fabricate.** If answering needs a journal fact you don't have on hand, dispatch `read` to find it rather than inventing.\n- **Don't mention internal systems, hooks, or prompt assembly.**\n\n## When To Dispatch A Talent\n\nDispatching is the exception, not the rule. **First ask: can I answer this from\nwhat I already have?** If yes, just answer. Dispatch only when the answer needs\na capability you lack — and pick the one that matches the *verb* of the request:\n\n- `read` — **find or understand something in the journal.** A past\n conversation, a name, a quote, a file, a memory; or synthesis across time,\n relationships, or themes. This is the default dispatch — most lookups and all\n reflection go here. Preserve concrete hints (relative date/time, place, named\n people, quoted phrases) in the task. A brief \"let me check the journal\" bridge\n is fine; the owner's history is their own local journal — never claim it's\n inaccessible. Lookup answers preserve provenance: name the transcript, entry,\n or file evidence, or say it's thin — never synthesize a confident answer from\n a tool's error text.\n- `exec` — **do or change something.** Edit an entity, adjust an activity,\n set the journal name/owner. Dispatch only when the owner clearly wants an\n action taken, and pass the specific change in the task.\n- `support` — **sol pbc help.** A bug report, a help request, feedback, or\n checking a ticket. The support talent files and monitors with sol pbc under\n the owner's consent.\n\n**Do NOT dispatch for:** greetings, thanks, acknowledgements, brief follow-ups,\nquestions about your role/capabilities, or generic \"what's up\" queries that need\nno new work.\n\nWhen dispatching, set `talent_request.context` to a compact JSON-encoded string of hints (e.g., `\"{\\\"person\\\":\\\"Adrian\\\"}\"`), or `null` when there are no hints. Never emit a raw JSON object.\n\n## Stop-And-Report Contract\n\nWhen this turn is a `talent_finished` or `talent_errored` follow-up (the latest message will say `[internal follow-up: talent ... finished ...]`):\n\n- **Set `talent_request: null`.** Do not dispatch another talent.\n- **Synthesize the result for the owner.** Use the talent's summary/reason to write the actual owner-facing reply, preserving provenance when this was a lookup.\n- **The previous turn already wrote a \"let me check...\" bridge.** Now is the time to deliver the answer or report the failure.\n\n## JSON Output Contract\n\nReturn exactly one JSON object matching `chat.schema.json`:\n\n- `message`: The owner-facing reply, written naturally. Use `null` only when you genuinely have no safe or useful message to send.\n- `notes`: One concise internal sentence explaining your choice. No long reasoning dumps.\n- `talent_request`: `null` unless dispatching (rare). When dispatching, include `target` (`read`, `exec`, or `support`), `task` (the specific work), and `context` (compact JSON-encoded string of hints, or `null`).\n\nReturn JSON only.", "multi_facet": false, "name": "chat", "title": "Chat" diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index 10b0b5152..1bb1259fe 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -269,17 +269,6 @@ "title": "Read", "type": "cogitate" }, - "routine": { - "app": null, - "color": "#6c757d", - "description": "User-defined routine execution — runs owner instructions on schedule", - "multi_facet": false, - "output_format": null, - "schedule": "none", - "source": "system", - "title": "Routine", - "type": "cogitate" - }, "schedule": { "app": null, "color": "#5e35b1", diff --git a/tests/test_chat_context.py b/tests/test_chat_context.py index b939e14fb..12bb776a3 100644 --- a/tests/test_chat_context.py +++ b/tests/test_chat_context.py @@ -5,15 +5,11 @@ import importlib.util import json import re import sys -from copy import deepcopy from datetime import datetime from pathlib import Path -import pytest - from solstone.convey.chat_stream import append_chat_event from solstone.convey.sol_initiated.copy import KIND_SOL_CHAT_REQUEST -from solstone.think.utils import CorruptConfigError TEMPLATE_VAR_KEYS = { "active_talents", @@ -25,8 +21,6 @@ TEMPLATE_VAR_KEYS = { "since_ts", "trigger_talent", "location", - "active_routines", - "routine_suggestion", } @@ -63,15 +57,6 @@ def _ts(hour: int, minute: int, second: int = 0) -> int: return int(datetime(2026, 4, 20, hour, minute, second).timestamp() * 1000) -def _stub_routines(monkeypatch) -> None: - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - - def _append_owner_message(text: str, ts: int, **extra) -> None: append_chat_event( "owner_message", @@ -100,9 +85,7 @@ def _chat_prompt_frontmatter() -> dict: return metadata -def test_chat_context_injects_tail_trigger_location_and_routine_state( - monkeypatch, tmp_path -): +def test_chat_context_injects_tail_trigger_location(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) _write_journal_config( @@ -140,39 +123,6 @@ def test_chat_context_injects_tail_trigger_location_and_routine_state( started_at=_ts(9, 2), ) - routines_config = { - "_meta": { - "suggestions_enabled": True, - "suggestions": { - "meeting-prep": { - "trigger_count": 3, - "first_trigger": "2026-04-01", - "last_trigger": "2026-04-19", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - } - monkeypatch.setattr( - "solstone.think.routines.get_routine_state", - lambda: [ - { - "name": "Morning Briefing", - "cadence": "0 9 * * *", - "last_run": None, - "enabled": True, - "paused_until": None, - "output_summary": "Shared the top priorities.", - } - ], - ) - monkeypatch.setattr( - "solstone.think.routines.get_config", lambda: deepcopy(routines_config) - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - result = _load_chat_context_module().pre_process( { "prompt": "Please brief me for my meeting", @@ -201,10 +151,6 @@ def test_chat_context_injects_tail_trigger_location_and_routine_state( assert "## Location" in template_vars["location"] assert "/app/home" in template_vars["location"] assert "work" in template_vars["location"] - assert "## Active Routines" in template_vars["active_routines"] - assert "Morning Briefing" in template_vars["active_routines"] - assert "Routine Suggestion Eligible" in template_vars["routine_suggestion"] - assert "meeting-prep" in template_vars["routine_suggestion"] def test_chat_prompt_has_no_digest_slot(): @@ -217,7 +163,6 @@ def test_chat_prompt_has_no_digest_slot(): def test_chat_context_owner_message_anchors_empty_tail(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( { @@ -237,7 +182,6 @@ def test_chat_context_owner_message_anchors_empty_tail(monkeypatch, tmp_path): def test_chat_context_owner_message_anchors_after_sol_message(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) _append_owner_message("thanks", _ts(8, 0)) append_chat_event( @@ -271,7 +215,6 @@ def test_chat_context_owner_message_anchors_after_sol_message(monkeypatch, tmp_p def test_chat_context_owner_message_anchor_is_idempotent(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) _append_owner_message("what's my name?", _ts(8, 0)) @@ -292,7 +235,6 @@ def test_chat_context_owner_message_anchor_is_idempotent(monkeypatch, tmp_path): def test_chat_context_owner_message_anchors_after_different_user(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) _append_owner_message("thanks", _ts(8, 0)) @@ -316,7 +258,6 @@ def test_chat_context_owner_message_anchors_after_different_user(monkeypatch, tm def test_chat_context_prompt_only_owner_message_anchors(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( {"prompt": "what's my name?", "day": "20260420"} @@ -328,7 +269,6 @@ def test_chat_context_prompt_only_owner_message_anchors(monkeypatch, tmp_path): def test_chat_context_owner_message_anchors_when_tail_read_fails(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) module = _load_chat_context_module() def _boom(*_args, **_kwargs): @@ -354,7 +294,6 @@ def test_chat_context_owner_message_anchors_when_tail_read_fails(monkeypatch, tm def test_chat_context_talent_finished_does_not_owner_anchor(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) _append_owner_message("What happened?", _ts(8, 0)) append_chat_event( @@ -390,7 +329,6 @@ def test_chat_context_talent_finished_does_not_owner_anchor(monkeypatch, tmp_pat def test_chat_context_talent_errored_does_not_owner_anchor(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) _append_owner_message("What happened?", _ts(8, 0)) append_chat_event( @@ -428,7 +366,6 @@ def test_chat_context_owner_message_renders_empty_trigger_context( ): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( { @@ -451,7 +388,6 @@ def test_chat_context_sol_initiated_still_renders_trigger_context( ): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( { @@ -476,7 +412,6 @@ def test_chat_context_talent_finished_still_renders_trigger_context( ): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( { @@ -499,7 +434,6 @@ def test_chat_context_talent_errored_still_renders_trigger_context( ): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) result = _load_chat_context_module().pre_process( { @@ -522,7 +456,6 @@ def test_chat_context_owner_message_needs_you_source_becomes_trigger_context( ): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _stub_routines(monkeypatch) source = {"kind": "needs_you", "item_text": "Review the launch checklist"} _append_owner_message("Review the launch checklist", _ts(8, 0), source=source) @@ -551,53 +484,6 @@ def test_chat_context_owner_message_needs_you_source_becomes_trigger_context( assert "## Trigger Context" not in result["template_vars"]["trigger_context"] -def test_chat_context_routine_suggestion_only_counts_owner_messages( - monkeypatch, tmp_path -): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - - routines_config = {"_meta": {"suggestions_enabled": True, "suggestions": {}}} - save_calls: list[dict] = [] - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr("solstone.think.routines.get_config", lambda: routines_config) - monkeypatch.setattr( - "solstone.think.routines.save_config", - lambda config: save_calls.append(deepcopy(config)), - ) - - module = _load_chat_context_module() - - module.pre_process( - { - "prompt": "What is on my calendar today?", - "trigger": { - "type": "talent_finished", - "name": "exec", - "summary": "Collected the latest meeting prep notes.", - }, - } - ) - - assert routines_config["_meta"]["suggestions"] == {} - assert save_calls == [] - - module.pre_process( - { - "prompt": "What is on my calendar today?", - "trigger": { - "type": "owner_message", - "message": "What is on my calendar today?", - "ts": _ts(10, 0), - }, - } - ) - - suggestion = routines_config["_meta"]["suggestions"]["morning-briefing"] - assert suggestion["trigger_count"] == 1 - assert len(save_calls) == 1 - - def test_chat_context_talent_finished_marks_stop_and_report(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) @@ -627,13 +513,6 @@ def test_chat_context_talent_finished_marks_stop_and_report(monkeypatch, tmp_pat summary="Found the latest notes.", ) - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - result = _load_chat_context_module().pre_process( { "day": "20260420", @@ -696,13 +575,6 @@ def test_chat_context_talent_errored_marks_stop_and_report(monkeypatch, tmp_path reason="The lookup failed.", ) - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - result = _load_chat_context_module().pre_process( { "day": "20260420", @@ -758,13 +630,6 @@ def test_chat_context_talent_followups_are_observably_distinct(monkeypatch, tmp_ requested_task=None, ) - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - module = _load_chat_context_module() finished = module.pre_process( { @@ -835,62 +700,18 @@ def test_chat_context_talent_followups_are_observably_distinct(monkeypatch, tmp_ assert "- Reason: The lookup failed." in errored_vars["trigger_context"] -def test_chat_context_preserves_save_routines_config_side_effect(monkeypatch, tmp_path): +def test_chat_context_omits_messages_when_empty(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - routines_config = {"_meta": {"suggestions_enabled": True, "suggestions": {}}} - save_calls: list[dict] = [] - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr("solstone.think.routines.get_config", lambda: routines_config) - monkeypatch.setattr( - "solstone.think.routines.save_config", - lambda config: save_calls.append(deepcopy(config)), - ) - - _load_chat_context_module().pre_process( - { - "prompt": "What is on my calendar today?", - "trigger": { - "type": "owner_message", - "message": "What is on my calendar today?", - "ts": _ts(11, 0), - }, - } - ) - - assert len(save_calls) == 1 - saved = save_calls[0] - assert saved["_meta"]["suggestions"]["morning-briefing"]["trigger_count"] == 1 - assert saved["_meta"]["suggestions"]["morning-briefing"]["first_trigger"] - - -def test_chat_context_routines_omitted_when_empty(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - result = _load_chat_context_module().pre_process({"day": "20260420"}) template_vars = _assert_template_vars_result(result) - assert template_vars["active_routines"] == "" assert template_vars["active_talents"] == "" assert "messages" not in result def test_pre_process_exposes_latest_owner_message_source(monkeypatch, tmp_path): - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) source = {"kind": "needs_you", "item_text": "Review the launch checklist"} @@ -946,9 +767,6 @@ def test_chat_context_enrichment_errors_are_graceful(monkeypatch, tmp_path): monkeypatch.setattr(module, "read_chat_tail", _boom) monkeypatch.setattr(module, "reduce_chat_state", _boom) - monkeypatch.setattr("solstone.think.routines.get_routine_state", _boom) - monkeypatch.setattr("solstone.think.routines.get_config", _boom) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) result = module.pre_process( { @@ -965,8 +783,6 @@ def test_chat_context_enrichment_errors_are_graceful(monkeypatch, tmp_path): template_vars = _assert_template_vars_result(result) assert template_vars["active_talents"] == "" - assert template_vars["active_routines"] == "" - assert template_vars["routine_suggestion"] == "" assert template_vars["trigger_context"] == "" assert "/app/home" in template_vars["location"] assert result["messages"] == [ @@ -974,30 +790,7 @@ def test_chat_context_enrichment_errors_are_graceful(monkeypatch, tmp_path): ] -def test_chat_context_corrupt_config_re_raises_from_routine_suggestion( - monkeypatch, tmp_path -): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - module = _load_chat_context_module() - - def _raise_corrupt_config(): - raise CorruptConfigError(journal / "config" / "journal.json") - - monkeypatch.setattr(module, "render_routine_suggestion", _raise_corrupt_config) - - with pytest.raises(CorruptConfigError): - module.pre_process({"day": "20260420"}) - - def test_chat_context_drops_legacy_memory_imports(monkeypatch): - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - legacy_module = "think" + ".con" + "versation" legacy_memory = "conversation_" + "memory" source = ( diff --git a/tests/test_chat_context_sol_initiated.py b/tests/test_chat_context_sol_initiated.py index 85d97c68f..3f63ef89c 100644 --- a/tests/test_chat_context_sol_initiated.py +++ b/tests/test_chat_context_sol_initiated.py @@ -9,18 +9,8 @@ from solstone.convey.sol_initiated.copy import ( from solstone.talent import chat_context -def _patch_routines(monkeypatch) -> None: - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - - def test_pre_hook_sets_template_vars_for_sol_request(monkeypatch, tmp_path) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - _patch_routines(monkeypatch) since_ts = 1_775_000_000_000 result = chat_context.pre_process( @@ -52,7 +42,6 @@ def test_pre_hook_sets_template_vars_for_sol_request(monkeypatch, tmp_path) -> N def test_existing_trigger_kind_labels_remain(monkeypatch, tmp_path) -> None: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - _patch_routines(monkeypatch) owner = chat_context.pre_process( { diff --git a/tests/test_chat_runtime.py b/tests/test_chat_runtime.py index 78e65e27f..a4047b520 100644 --- a/tests/test_chat_runtime.py +++ b/tests/test_chat_runtime.py @@ -570,12 +570,6 @@ def test_terminal_talent_reports_back_without_redispatch( monkeypatch.setattr( "solstone.convey.chat._emit_cortex_event", lambda *args, **kwargs: None ) - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) spawns: list[dict] = [] diff --git a/tests/test_check_cogitate_prompts.py b/tests/test_check_cogitate_prompts.py index c0ca955af..2daead9f7 100644 --- a/tests/test_check_cogitate_prompts.py +++ b/tests/test_check_cogitate_prompts.py @@ -82,7 +82,7 @@ def test_bare_journal_flags_fenced_commands() -> None: 2, "bare-journal", "forbidden `journal supervisor`; use `journal` with one of " - "{identity, routines, health, talent}, or use `sol`/`sol call`", + "{identity, health, talent}, or use `sol`/`sol call`", ) ] @@ -92,7 +92,6 @@ def test_bare_journal_flags_fenced_commands() -> None: [ "`journal identity pulse --write --value '>'`", "`journal identity pulse --write --value '|'`", - "`journal routines list`", "`journal health`", "`journal talent logs --daily`", "`sol doctor`", @@ -194,14 +193,14 @@ def test_extract_command_spans_scans_fences_per_line_without_inline_double_scan( "echo `journal health`\n" "\n" "```\n" - "After `journal routines list`.\n" + "After `journal identity pulse`.\n" ) assert ccp.extract_command_spans(body) == [ (1, "journal navigate"), (3, "journal supervisor"), (4, "echo `journal health`"), - (7, "journal routines list"), + (7, "journal identity pulse"), ] findings = ccp.lint_prompt(body) diff --git a/tests/test_cogitate_policy.py b/tests/test_cogitate_policy.py index 09d7cf1e3..6fef4d22d 100644 --- a/tests/test_cogitate_policy.py +++ b/tests/test_cogitate_policy.py @@ -54,8 +54,6 @@ def test_policy_denies_write_tools(tmp_path): [ "journal identity pulse", "journal identity awareness --write --value update", - "journal routines list", - "journal routines output morning", "journal health logs --since 1h", "journal talent logs --daily -c 10", "journal identity pulse --write --value 'a; quoted value'", diff --git a/tests/test_exec_context.py b/tests/test_exec_context.py deleted file mode 100644 index 96af21e5e..000000000 --- a/tests/test_exec_context.py +++ /dev/null @@ -1,261 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -import importlib.util -import json -from pathlib import Path - -TEMPLATE_VAR_KEYS = frozenset({"active_routines", "routine_suggestion"}) - - -def _load_exec_context_module(): - path = ( - Path(__file__).resolve().parents[1] / "solstone" / "talent" / "exec_context.py" - ) - spec = importlib.util.spec_from_file_location("test_exec_context_local", path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _load_chat_context_module(): - path = ( - Path(__file__).resolve().parents[1] / "solstone" / "talent" / "chat_context.py" - ) - spec = importlib.util.spec_from_file_location("test_chat_context_local", path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _assert_template_vars_result(result): - assert set(result.keys()) == {"template_vars"} - assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS - return result["template_vars"] - - -def _write_journal_config(journal: Path, data: dict) -> None: - config_dir = journal / "config" - config_dir.mkdir(parents=True, exist_ok=True) - (config_dir / "journal.json").write_text( - json.dumps(data, indent=2), - encoding="utf-8", - ) - - -def test_exec_pre_process_populated_state(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _write_journal_config( - journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} - ) - - monkeypatch.setattr( - "solstone.think.routines.get_routine_state", - lambda: [ - { - "name": "Morning Briefing", - "cadence": "0 9 * * *", - "last_run": None, - "enabled": True, - "paused_until": None, - "output_summary": "Shared the top priorities.", - } - ], - ) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: { - "_meta": { - "suggestions_enabled": True, - "suggestions": { - "meeting-prep": { - "trigger_count": 3, - "first_trigger": "2026-04-01", - "last_trigger": "2026-04-19", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - }, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - - result = _load_exec_context_module().pre_process({"day": "20260420"}) - - template_vars = _assert_template_vars_result(result) - assert "## Active Routines" in template_vars["active_routines"] - assert "Morning Briefing" in template_vars["active_routines"] - assert "## Routine Suggestion Eligible" in template_vars["routine_suggestion"] - assert "meeting-prep" in template_vars["routine_suggestion"] - assert ( - "journal routines suggest-respond meeting-prep --accepted" - in template_vars["routine_suggestion"] - ) - - -def test_exec_pre_process_empty_state(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _write_journal_config( - journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} - ) - - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - - result = _load_exec_context_module().pre_process({"day": "20260420"}) - - template_vars = _assert_template_vars_result(result) - assert template_vars["active_routines"] == "" - assert template_vars["routine_suggestion"] == "" - - -def test_exec_pre_process_errors_swallowed(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _write_journal_config( - journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} - ) - - module = _load_exec_context_module() - - def _boom(*_args, **_kwargs): - raise RuntimeError("boom") - - monkeypatch.setattr("solstone.think.routines.get_routine_state", _boom) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", _boom) - - result = module.pre_process({"day": "20260420"}) - template_vars = _assert_template_vars_result(result) - assert template_vars["active_routines"] == "" - - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr("solstone.think.routines.get_config", _boom) - - result = module.pre_process({"day": "20260420"}) - template_vars = _assert_template_vars_result(result) - assert template_vars["routine_suggestion"] == "" - - -def test_exec_pre_process_returned_dict_shape(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", lambda config: None) - - result = _load_exec_context_module().pre_process({"day": "20260420"}) - - assert set(result.keys()) == {"template_vars"} - assert set(result["template_vars"].keys()) == TEMPLATE_VAR_KEYS - - -def test_exec_pre_process_never_calls_save_config(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _write_journal_config( - journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} - ) - - def _fail_save(*_args, **_kwargs): - raise AssertionError("save_config should not be called") - - monkeypatch.setattr("solstone.think.routines.get_routine_state", lambda: []) - monkeypatch.setattr( - "solstone.think.routines.get_config", - lambda: {"_meta": {"suggestions_enabled": True, "suggestions": {}}}, - ) - monkeypatch.setattr("solstone.think.routines.save_config", _fail_save) - - module = _load_exec_context_module() - owner_result = module.pre_process( - { - "prompt": "What is on my calendar today?", - "trigger_kind": "owner_message", - "trigger_payload": {"text": "What is on my calendar today?"}, - } - ) - talent_result = module.pre_process( - { - "trigger_kind": "talent_finished", - "trigger_payload": {"name": "exec", "summary": "Done."}, - } - ) - - _assert_template_vars_result(owner_result) - _assert_template_vars_result(talent_result) - - -def test_exec_and_chat_render_identical_routine_vars(monkeypatch, tmp_path): - journal = tmp_path / "journal" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - _write_journal_config( - journal, {"agent": {"name": "Sol-agent", "name_status": "custom"}} - ) - - routine_state = [ - { - "name": "Morning Briefing", - "cadence": "0 9 * * *", - "last_run": None, - "enabled": True, - "paused_until": None, - "output_summary": "Shared the top priorities.", - } - ] - routines_config = { - "_meta": { - "suggestions_enabled": True, - "suggestions": { - "meeting-prep": { - "trigger_count": 3, - "first_trigger": "2026-04-01", - "last_trigger": "2026-04-19", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - } - - def _fail_save(*_args, **_kwargs): - raise AssertionError("save_config should not be called") - - monkeypatch.setattr( - "solstone.think.routines.get_routine_state", lambda: routine_state - ) - monkeypatch.setattr("solstone.think.routines.get_config", lambda: routines_config) - monkeypatch.setattr("solstone.think.routines.save_config", _fail_save) - - context = {"day": "20260420"} - chat_result = _load_chat_context_module().pre_process(context) - exec_result = _load_exec_context_module().pre_process(context) - - assert ( - chat_result["template_vars"]["active_routines"] - == exec_result["template_vars"]["active_routines"] - ) - assert ( - chat_result["template_vars"]["routine_suggestion"] - == exec_result["template_vars"]["routine_suggestion"] - ) diff --git a/tests/test_home_reflections.py b/tests/test_home_reflections.py index 38fa9bdce..ab94a59f3 100644 --- a/tests/test_home_reflections.py +++ b/tests/test_home_reflections.py @@ -38,7 +38,6 @@ def _minimal_pulse_context(latest_weekly_reflection): "flow_updated_at": None, "anticipated_activities": [], "activities": [], - "routines": [], "briefing_sections": {}, "briefing_meta": None, "briefing_phase": "eod", @@ -57,7 +56,6 @@ def _minimal_pulse_context(latest_weekly_reflection): }, "show_welcome": False, "narrative_summary": "", - "routines_summary": "", "today_summary": "", "needs_summary": "", } diff --git a/tests/test_home_routines.py b/tests/test_home_routines.py deleted file mode 100644 index cbb06feec..000000000 --- a/tests/test_home_routines.py +++ /dev/null @@ -1,232 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for home pulse routine surfacing.""" - -import json -from datetime import datetime, timedelta - -import pytest - -from solstone.apps.home.routes import ( - _collect_routines, - _load_routines_state, - _save_routines_state, - home_bp, -) - - -@pytest.fixture -def home_client(): - """Create a Flask test client with home routes registered.""" - from flask import Flask - - app = Flask(__name__) - app.register_blueprint(home_bp) - return app.test_client() - - -def _write_routines_config(tmp_path, config): - routines_dir = tmp_path / "routines" - routines_dir.mkdir(exist_ok=True) - (routines_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") - - -def test_collect_routines_empty_config(monkeypatch, tmp_path): - """Missing routines config yields no pulse routines.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - assert _collect_routines() == [] - - -def test_collect_routines_with_recent_output(monkeypatch, tmp_path): - """Recent enabled routine output is returned with an extracted summary.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - routine_id = "morning-briefing" - _write_routines_config( - tmp_path, - { - routine_id: { - "id": routine_id, - "name": "Morning Briefing", - "cadence": "0 8 * * *", - "enabled": True, - "last_run": (datetime.now() - timedelta(hours=2)).isoformat(), - } - }, - ) - output_dir = tmp_path / "routines" / routine_id - output_dir.mkdir() - (output_dir / "20260327.md").write_text( - "---\nupdated: 2026-03-27T08:00:00\n---\n# Heading\n\nYour day looks clear with one meeting at 2pm.\n", - encoding="utf-8", - ) - - routines = _collect_routines() - - assert len(routines) == 1 - assert routines[0]["id"] == routine_id - assert routines[0]["name"] == "Morning Briefing" - assert routines[0]["summary"] == "Your day looks clear with one meeting at 2pm." - assert routines[0]["seen"] is False - - -def test_collect_routines_multi_output_picks_newest(monkeypatch, tmp_path): - """When multiple outputs exist for a routine, the newest by mtime is used.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - routine_id = "multi-run" - _write_routines_config( - tmp_path, - { - routine_id: { - "id": routine_id, - "name": "Multi Run", - "cadence": "0 8 * * *", - "enabled": True, - "last_run": (datetime.now() - timedelta(hours=1)).isoformat(), - } - }, - ) - output_dir = tmp_path / "routines" / routine_id - output_dir.mkdir() - # Older file (plain date name) - older = output_dir / "20260327.md" - older.write_text("Old output from first run.", encoding="utf-8") - # Ensure mtime difference - time.sleep(0.05) - # Newer file (collision name with timestamp) - newer = output_dir / "20260327-120000.md" - newer.write_text("Updated output from second run.", encoding="utf-8") - - routines = _collect_routines() - - assert len(routines) == 1 - assert routines[0]["summary"] == "Updated output from second run." - - -def test_collect_routines_stale_excluded(monkeypatch, tmp_path): - """Stale routine runs are excluded from pulse.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - _write_routines_config( - tmp_path, - { - "morning-briefing": { - "id": "morning-briefing", - "name": "Morning Briefing", - "cadence": "0 8 * * *", - "enabled": True, - "last_run": (datetime.now() - timedelta(days=2)).isoformat(), - } - }, - ) - - assert _collect_routines() == [] - - -def test_collect_routines_disabled_excluded(monkeypatch, tmp_path): - """Disabled routines are excluded even with recent runs.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - _write_routines_config( - tmp_path, - { - "morning-briefing": { - "id": "morning-briefing", - "name": "Morning Briefing", - "cadence": "0 8 * * *", - "enabled": False, - "last_run": datetime.now().isoformat(), - } - }, - ) - - assert _collect_routines() == [] - - -def test_collect_routines_seen_flag(monkeypatch, tmp_path): - """Routine runs before the last-seen marker are marked seen.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - last_run = datetime.now() - timedelta(hours=2) - _write_routines_config( - tmp_path, - { - "morning-briefing": { - "id": "morning-briefing", - "name": "Morning Briefing", - "cadence": "0 8 * * *", - "enabled": True, - "last_run": last_run.isoformat(), - } - }, - ) - _save_routines_state( - {"routines_last_seen": (last_run + timedelta(minutes=30)).isoformat()} - ) - - routines = _collect_routines() - - assert len(routines) == 1 - assert routines[0]["seen"] is True - - -def test_api_routines_seen(monkeypatch, tmp_path, home_client): - """Seen endpoint persists the routines seen timestamp.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - resp = home_client.post("/app/home/api/routines/seen") - - assert resp.status_code == 200 - assert resp.get_json() == {"ok": True} - state = _load_routines_state() - assert "routines_last_seen" in state - - -def test_api_pulse_includes_routines(monkeypatch, home_client): - """Pulse API includes the routines payload from the context builder.""" - monkeypatch.setattr( - "solstone.apps.home.routes.get_capture_health", - lambda: {"status": "active", "observers": []}, - ) - monkeypatch.setattr("solstone.apps.home.routes.get_cached_state", lambda: {}) - monkeypatch.setattr( - "solstone.apps.home.routes._resolve_attention", lambda awareness: None - ) - monkeypatch.setattr("solstone.apps.home.routes._load_stats", lambda today: {}) - monkeypatch.setattr( - "solstone.apps.home.routes._load_flow_md", lambda today: (None, None) - ) - monkeypatch.setattr( - "solstone.apps.home.routes._load_pulse_md", lambda: (None, None, []) - ) - monkeypatch.setattr( - "solstone.apps.home.routes._collect_anticipated_activities", lambda today: [] - ) - monkeypatch.setattr( - "solstone.apps.home.routes._collect_activities", lambda today: [] - ) - monkeypatch.setattr( - "solstone.apps.home.routes._collect_routines", - lambda: [ - { - "id": "morning-briefing", - "name": "Morning Briefing", - "last_run": datetime.now().isoformat(), - "run_time_display": "just now", - "summary": "Clear day ahead", - "seen": False, - } - ], - ) - - resp = home_client.get("/app/home/api/pulse") - - assert resp.status_code == 200 - data = resp.get_json() - assert "routines" in data - assert data["routines"][0]["name"] == "Morning Briefing" diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index 0202f98eb..c5ccbf473 100644 --- a/tests/test_home_yesterdays_processing.py +++ b/tests/test_home_yesterdays_processing.py @@ -192,7 +192,6 @@ def _patch_minimal_pulse_context(monkeypatch, pipeline_status): monkeypatch.setattr( "solstone.apps.home.routes._collect_activities", lambda today: [] ) - monkeypatch.setattr("solstone.apps.home.routes._collect_routines", lambda: []) monkeypatch.setattr( "solstone.apps.home.routes.read_steward_health", lambda: pipeline_status, @@ -663,7 +662,6 @@ def test_build_pulse_context_includes_yesterday_processing(monkeypatch): monkeypatch.setattr( "solstone.apps.home.routes._collect_activities", lambda today: [] ) - monkeypatch.setattr("solstone.apps.home.routes._collect_routines", lambda: []) monkeypatch.setattr("solstone.apps.home.routes.read_steward_health", lambda: None) monkeypatch.setattr( "solstone.apps.home.routes._summarize_yesterday_processing", diff --git a/tests/test_local_command_home.py b/tests/test_local_command_home.py index 19696e869..c6a656235 100644 --- a/tests/test_local_command_home.py +++ b/tests/test_local_command_home.py @@ -15,7 +15,6 @@ from solstone.think.service import Reconciled LOCAL_COMMANDS = { "navigate": "solstone.think.tools.navigate", - "routines": "solstone.think.tools.routines", "identity": "solstone.think.tools.sol", "install-provider": "solstone.think.install_provider", } @@ -34,7 +33,6 @@ def test_local_commands_resolve_as_service(command: str, module: str) -> None: ("command", "extra_args"), [ ("navigate", ["/x"]), - ("routines", ["list"]), ("identity", ["partner"]), ("install-provider", ["local"]), ], @@ -122,7 +120,6 @@ def test_local_commands_are_journal_help_only(monkeypatch, capsys) -> None: ("args", "pointer"), [ (["navigate", "/x"], "journal navigate"), - (["routines", "list"], "journal routines"), (["identity"], "journal identity"), (["settings", "providers", "install", "local"], "journal install-provider"), ], @@ -145,5 +142,4 @@ def test_old_sol_call_help_lists_moved_stubs() -> None: assert result.exit_code == 0 assert "Moved to `journal navigate`." in result.output - assert "Moved to `journal routines`." in result.output assert "Moved to `journal identity`." in result.output diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index b80b40771..6061f8bd4 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -631,10 +631,6 @@ def test_supervisor_registers_maintenance_before_scheduler_init(tmp_path, monkey monkeypatch.setattr( mod.scheduler, "catch_up", lambda: order.append(("catch_up", None)) ) - monkeypatch.setattr( - mod.routines, "init", lambda _callosum: order.append(("routines.init", None)) - ) - monkeypatch.setattr(mod.routines, "save_state", lambda: None) def interrupt_supervise(coro): coro.close() diff --git a/tests/test_post_action_terminology.py b/tests/test_post_action_terminology.py index 566cd91c0..0a201d478 100644 --- a/tests/test_post_action_terminology.py +++ b/tests/test_post_action_terminology.py @@ -52,7 +52,6 @@ def _minimal_home_context() -> dict: "anticipated_activities": [], "activities": [], "needs_you_items": [item], - "routines": [], "briefing_sections": {}, "briefing_meta": None, "briefing_phase": "eod", @@ -66,7 +65,6 @@ def _minimal_home_context() -> dict: "yesterday_processing": None, "show_welcome": False, "narrative_summary": "", - "routines_summary": "", "today_summary": "", "needs_summary": "1 item needs attention", } diff --git a/tests/test_routines.py b/tests/test_routines.py deleted file mode 100644 index 27d4dc7d6..000000000 --- a/tests/test_routines.py +++ /dev/null @@ -1,2143 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for routines — user-defined routines engine.""" - -import importlib -import importlib.util -import json -from contextlib import contextmanager -from datetime import date, datetime, timedelta, timezone -from pathlib import Path -from unittest.mock import patch - -import frontmatter -import pytest -import typer -from typer.testing import CliRunner - -from solstone.think import routines -from solstone.think.routines import cron_matches, get_config, save_config -from solstone.think.tools.routines import app as _routines_app - -runner = CliRunner() -call_app = typer.Typer() -call_app.add_typer(_routines_app, name="routines") - - -def _load_chat_context_module(): - """Load talent.chat_context from this worktree explicitly for tests.""" - path = ( - Path(__file__).resolve().parents[1] / "solstone" / "talent" / "chat_context.py" - ) - spec = importlib.util.spec_from_file_location("test_chat_context", path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _load_routine_context_module(): - """Load talent._routine_context from this worktree explicitly for tests.""" - module = importlib.import_module("solstone.talent._routine_context") - return importlib.reload(module) - - -def _load_routines_cli_module(): - """Load think.tools.routines from this worktree explicitly for tests.""" - path = ( - Path(__file__).resolve().parents[1] - / "solstone" - / "think" - / "tools" - / "routines.py" - ) - spec = importlib.util.spec_from_file_location("test_routines_cli", path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -@contextmanager -def _fake_now(dt: datetime): - """Temporarily replace routines.datetime with a fake that returns dt.""" - - class _FakeDatetime(datetime): - @classmethod - def now(cls, tz=None): - if tz is None: - return dt - if dt.tzinfo is None: - return dt.replace(tzinfo=tz) - return dt.astimezone(tz) - - routines.datetime = _FakeDatetime - try: - yield - finally: - routines.datetime = datetime - - -@pytest.fixture(autouse=True) -def reset_routines_state(): - """Reset routines module state between tests.""" - import solstone.think.routines as mod - - mod._config = {} - mod._callosum = None - mod._last_fired = {} - mod._fired_triggers = {} - mod._logged_unknown_cadence = set() - yield - mod._config = {} - mod._callosum = None - mod._last_fired = {} - mod._fired_triggers = {} - mod._logged_unknown_cadence = set() - - -@pytest.fixture -def journal_path(tmp_path, monkeypatch): - """Create a temp journal with routines/ and health/ dirs.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - (tmp_path / "routines").mkdir() - (tmp_path / "health").mkdir() - return tmp_path - - -class TestCronMatches: - def test_wildcard_all(self): - dt = datetime(2026, 3, 15, 9, 30) - assert cron_matches("* * * * *", dt) is True - - def test_specific_values(self): - assert cron_matches("30 9 15 3 *", datetime(2026, 3, 15, 9, 30)) is True - assert cron_matches("30 9 15 3 *", datetime(2026, 3, 15, 9, 31)) is False - - def test_comma_list(self): - assert cron_matches("0,15,30,45 * * * *", datetime(2026, 3, 15, 9, 15)) is True - assert cron_matches("0,15,30,45 * * * *", datetime(2026, 3, 15, 9, 10)) is False - - def test_range(self): - assert cron_matches("0 9-17 * * *", datetime(2026, 3, 15, 9, 0)) is True - assert cron_matches("0 9-17 * * *", datetime(2026, 3, 15, 18, 0)) is False - - def test_step(self): - assert cron_matches("*/15 * * * *", datetime(2026, 3, 15, 9, 45)) is True - assert cron_matches("*/15 * * * *", datetime(2026, 3, 15, 9, 44)) is False - - def test_range_with_step(self): - assert cron_matches("0 1-23/2 * * *", datetime(2026, 3, 15, 9, 0)) is True - assert cron_matches("0 1-23/2 * * *", datetime(2026, 3, 15, 10, 0)) is False - - def test_dow_sunday_zero(self): - sunday = datetime(2026, 3, 29, 0, 0) - assert sunday.isoweekday() == 7 - assert cron_matches("0 0 * * 0", sunday) is True - - def test_dow_sunday_seven(self): - sunday = datetime(2026, 3, 29, 0, 0) - assert cron_matches("0 0 * * 7", sunday) is True - - def test_dow_monday(self): - monday = datetime(2026, 3, 30, 0, 0) - assert monday.isoweekday() == 1 - assert cron_matches("0 0 * * 1", monday) is True - - def test_invalid_field_count(self): - with pytest.raises(ValueError): - cron_matches("* * * *", datetime(2026, 3, 15, 9, 0)) - - def test_step_zero(self): - with pytest.raises(ValueError): - cron_matches("*/0 * * * *", datetime(2026, 3, 15, 9, 0)) - - def test_out_of_range(self): - with pytest.raises(ValueError): - cron_matches("60 * * * *", datetime(2026, 3, 15, 9, 0)) - - -class TestConfigIO: - def test_get_config_empty(self, journal_path): - assert get_config() == {} - - def test_save_and_get_config(self, journal_path): - routine = { - "abc123": { - "id": "abc123", - "name": "Morning", - "instruction": "Summarize today", - "cadence": "0 9 * * *", - "timezone": "UTC", - "facets": ["work"], - "enabled": True, - "created": "2026-03-27T00:00:00+00:00", - "last_run": None, - "template": None, - "notify": False, - } - } - save_config(routine) - loaded = get_config() - assert loaded == routine - - def test_save_config_creates_directory(self, journal_path): - (journal_path / "routines").rmdir() - save_config({"abc123": {"id": "abc123"}}) - assert (journal_path / "routines").exists() - assert (journal_path / "routines" / "config.json").exists() - - def test_get_config_corrupt_json(self, journal_path): - (journal_path / "routines" / "config.json").write_text("not json{") - assert get_config() == {} - - -class TestCheck: - def test_run_routine_request_config_has_no_outbound_approval( - self, journal_path, monkeypatch - ): - import solstone.think.routines as mod - - captured: dict = {} - - def fake_cortex_request(**kwargs): - captured.update(kwargs) - return "fake_agent_id" - - monkeypatch.setattr(mod, "cortex_request", fake_cortex_request) - monkeypatch.setattr(mod, "wait_for_uses", lambda _use_ids: ({}, [])) - monkeypatch.setattr(mod, "callosum_send", lambda *_args, **_kwargs: True) - - mod._run_routine( - { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "manual", - "facets": [], - "template": None, - "notify": False, - } - ) - - assert captured["name"] == "routine" - assert captured["config"] == { - "output_path": captured["config"]["output_path"], - "output": "md", - } - assert "outbound_approval" not in json.dumps(captured) - - def test_fires_due_routine(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - - dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_called_once() - - def test_skips_disabled_routine(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - - dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_not_called() - - def test_idempotent_same_minute(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - - dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - mod.check() - - assert mock_req.call_count == 1 - - def test_fires_again_next_minute(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Hourly", - "instruction": "Do the thing", - "cadence": "0 * * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - ): - with _fake_now(datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc)): - mod.check() - with _fake_now(datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc)): - mod.check() - - assert mock_req.call_count == 2 - - -class TestCLI: - def test_create_routine(self, journal_path): - result = runner.invoke( - call_app, - [ - "routines", - "create", - "--name", - "Morning review", - "--instruction", - "Review the day", - "--cadence", - "0 9 * * *", - ], - ) - assert result.exit_code == 0 - config = get_config() - assert len(config) == 1 - routine = next(iter(config.values())) - assert routine["name"] == "Morning review" - - def test_list_routines(self, journal_path): - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning review", - "instruction": "Review the day", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke(call_app, ["routines", "list"]) - assert result.exit_code == 0 - assert "Morning review" in result.stdout - - def test_list_empty(self, journal_path): - result = runner.invoke(call_app, ["routines", "list"]) - assert result.exit_code == 0 - assert "No routines configured." in result.stdout - - -class TestTemplates: - def test_templates_command_lists_all(self): - result = runner.invoke(call_app, ["routines", "templates"]) - assert result.exit_code == 0 - for template_name in ( - "morning-briefing", - "weekly-review", - "domain-watch", - "relationship-pulse", - "commitment-audit", - "monthly-patterns", - "meeting-prep", - ): - assert template_name in result.stdout - - def test_template_frontmatter_valid(self): - from solstone.think.tools.routines import _templates_dir - - templates = sorted(_templates_dir().glob("*.md")) - assert templates, "no routine templates found" - for path in templates: - post = frontmatter.load(path) - assert post.metadata["name"] - assert post.metadata["description"] - assert "default_cadence" in post.metadata - assert post.content.strip() - - def test_templates_dir_is_package_relative(self): - import solstone - from solstone.think.tools.routines import _templates_dir - from solstone.think.utils import get_project_root - - tpl_dir = _templates_dir().resolve() - package_root = Path(solstone.__file__).resolve().parent - - # Anchored inside the solstone package, never on the repo root. The - # pre-fix get_project_root()/routines/templates path fails all three. - assert tpl_dir.is_relative_to(package_root) - assert tpl_dir != (Path(get_project_root()) / "routines" / "templates") - assert tpl_dir.is_dir() - - expected = { - "commitment-audit", - "decision-review", - "domain-watch", - "energy-audit", - "meeting-prep", - "monthly-patterns", - "morning-briefing", - "relationship-pulse", - "weekly-review", - } - assert {p.stem for p in tpl_dir.glob("*.md")} == expected - - -class TestTemplateCreate: - def test_create_from_template(self, journal_path): - result = runner.invoke( - call_app, - ["routines", "create", "--template", "morning-briefing"], - ) - assert result.exit_code == 0 - config = get_config() - assert len(config) == 1 - routine = next(iter(config.values())) - assert routine["name"] == "morning-briefing" - assert routine["cadence"] == "0 7 * * *" - assert routine["template"] == "morning-briefing" - assert "daily morning briefing" in routine["instruction"].lower() - - def test_create_template_with_overrides(self, journal_path): - result = runner.invoke( - call_app, - [ - "routines", - "create", - "--template", - "morning-briefing", - "--cadence", - "0 8 * * *", - "--name", - "My Briefing", - ], - ) - assert result.exit_code == 0 - config = get_config() - routine = next(iter(config.values())) - assert routine["name"] == "My Briefing" - assert routine["cadence"] == "0 8 * * *" - assert routine["template"] == "morning-briefing" - - def test_create_template_not_found(self, journal_path): - result = runner.invoke( - call_app, - ["routines", "create", "--template", "nonexistent"], - ) - assert result.exit_code == 1 - assert "template 'nonexistent' not found" in result.stderr - - def test_create_template_dict_cadence_persisted(self, journal_path): - result = runner.invoke( - call_app, - ["routines", "create", "--template", "meeting-prep"], - ) - assert result.exit_code == 0 - config = get_config() - assert len(config) == 1 - routine = next(iter(config.values())) - assert routine["cadence"] == { - "type": "activity-anticipation", - "offset_minutes": -30, - } - - def test_create_template_dict_cadence_overridden_by_string(self, journal_path): - result = runner.invoke( - call_app, - [ - "routines", - "create", - "--template", - "meeting-prep", - "--cadence", - "0 9 * * *", - ], - ) - assert result.exit_code == 0 - config = get_config() - assert len(config) == 1 - routine = next(iter(config.values())) - assert routine["cadence"] == "0 9 * * *" - - def test_create_invalid_template_cadence_type(self, journal_path, monkeypatch): - import solstone.think.tools.routines as routines_cli - - def _fake_template(name: str): - return ( - { - "name": name, - "description": "bad template", - "default_cadence": { - "type": "event", - "trigger": "wrong", - "offset_minutes": -30, - }, - "default_timezone": "UTC", - "default_facets": [], - }, - "Instruction body", - ) - - monkeypatch.setattr(routines_cli, "_load_template", _fake_template) - result = runner.invoke( - call_app, - ["routines", "create", "--template", "bad-template"], - ) - assert result.exit_code == 1 - assert "unsupported cadence type" in result.stderr - - def test_create_template_dict_cadence_missing_type(self, journal_path, monkeypatch): - import solstone.think.tools.routines as routines_cli - - def _fake_template(name: str): - return ( - { - "name": name, - "description": "missing cadence type", - "default_cadence": {"offset_minutes": -30}, - "default_timezone": "UTC", - "default_facets": [], - }, - "Instruction body", - ) - - monkeypatch.setattr(routines_cli, "_load_template", _fake_template) - result = runner.invoke( - call_app, - ["routines", "create", "--template", "bad-template"], - ) - assert result.exit_code == 1 - assert "type" in result.stderr - assert "missing" in result.stderr - - def test_create_template_dict_cadence_bad_offset_minutes( - self, journal_path, monkeypatch - ): - import solstone.think.tools.routines as routines_cli - - def _fake_template(name: str): - return ( - { - "name": name, - "description": "bad cadence offset", - "default_cadence": { - "type": "activity-anticipation", - "offset_minutes": "not-a-number", - }, - "default_timezone": "UTC", - "default_facets": [], - }, - "Instruction body", - ) - - monkeypatch.setattr(routines_cli, "_load_template", _fake_template) - result = runner.invoke( - call_app, - ["routines", "create", "--template", "bad-template"], - ) - assert result.exit_code == 1 - assert "offset_minutes" in result.stderr - - -class TestNameResolution: - def test_resolve_by_name(self, journal_path): - save_config( - { - "abc-123-def": { - "id": "abc-123-def", - "name": "Morning Briefing", - "instruction": "Brief me", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "edit", "Morning Briefing", "--name", "Updated"] - ) - assert result.exit_code == 0 - config = get_config() - assert config["abc-123-def"]["name"] == "Updated" - - def test_resolve_by_name_case_insensitive(self, journal_path): - save_config( - { - "abc-123-def": { - "id": "abc-123-def", - "name": "Morning Briefing", - "instruction": "Brief me", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "edit", "morning briefing", "--name", "Updated"] - ) - assert result.exit_code == 0 - config = get_config() - assert config["abc-123-def"]["name"] == "Updated" - - def test_resolve_name_ambiguous(self, journal_path): - save_config( - { - "abc-123": { - "id": "abc-123", - "name": "Daily", - "instruction": "a", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - }, - "def-456": { - "id": "def-456", - "name": "Daily", - "instruction": "b", - "cadence": "0 10 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - }, - } - ) - result = runner.invoke(call_app, ["routines", "edit", "Daily", "--name", "X"]) - assert result.exit_code == 1 - assert "ambiguous" in result.stderr.lower() - - def test_meta_excluded_from_resolve(self, journal_path): - save_config( - { - "_meta": {"suggestions_enabled": True}, - "abc-123": { - "id": "abc-123", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - }, - } - ) - result = runner.invoke( - call_app, ["routines", "edit", "abc", "--name", "Updated"] - ) - assert result.exit_code == 0 - - -class TestResumeDate: - def test_edit_resume_date(self, journal_path): - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, - [ - "routines", - "edit", - "routine-1", - "--enabled", - "false", - "--resume-date", - "2026-04-01", - ], - ) - assert result.exit_code == 0 - config = get_config() - assert config["routine-1"]["resume_date"] == "2026-04-01" - - def test_enable_clears_resume_date(self, journal_path): - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - "resume_date": "2026-04-01", - } - } - ) - result = runner.invoke( - call_app, ["routines", "edit", "routine-1", "--enabled", "true"] - ) - assert result.exit_code == 0 - config = get_config() - assert config["routine-1"]["enabled"] is True - assert "resume_date" not in config["routine-1"] - - def test_auto_resume(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - "resume_date": "2026-03-27", - } - } - ) - - dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ), - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - config = get_config() - assert config["routine-1"]["enabled"] is True - assert "resume_date" not in config["routine-1"] - health_log = (journal_path / "health" / "routines.log").read_text() - assert "auto-resumed" in health_log - - def test_auto_resume_future_date_not_resumed(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - "resume_date": "2026-04-01", - } - } - ) - - dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ), - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - config = get_config() - assert config["routine-1"]["enabled"] is False - assert config["routine-1"]["resume_date"] == "2026-04-01" - - def test_resume_date_invalid_format(self, journal_path): - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "edit", "routine-1", "--resume-date", "not-a-date"] - ) - assert result.exit_code == 1 - assert "YYYY-MM-DD" in result.stderr - - -class TestOutputByDate: - def test_output_specific_date(self, journal_path): - output_dir = journal_path / "routines" / "routine-1" - output_dir.mkdir(parents=True) - (output_dir / "20260325.md").write_text("March 25 output", encoding="utf-8") - (output_dir / "20260326.md").write_text("March 26 output", encoding="utf-8") - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "output", "routine-1", "--date", "2026-03-25"] - ) - assert result.exit_code == 0 - assert "March 25 output" in result.stdout - - def test_output_date_missing(self, journal_path): - output_dir = journal_path / "routines" / "routine-1" - output_dir.mkdir(parents=True) - (output_dir / "20260325.md").write_text("content", encoding="utf-8") - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "output", "routine-1", "--date", "2026-03-27"] - ) - assert result.exit_code == 0 - assert "No output for that date" in result.stdout - - def test_output_date_collision_file(self, journal_path): - output_dir = journal_path / "routines" / "routine-1" - output_dir.mkdir(parents=True) - (output_dir / "20260325.md").write_text("first run", encoding="utf-8") - (output_dir / "20260325-093000.md").write_text("second run", encoding="utf-8") - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke( - call_app, ["routines", "output", "routine-1", "--date", "2026-03-25"] - ) - assert result.exit_code == 0 - assert "second run" in result.stdout - - def test_output_default_no_date(self, journal_path): - output_dir = journal_path / "routines" / "routine-1" - output_dir.mkdir(parents=True) - (output_dir / "20260325.md").write_text("old", encoding="utf-8") - (output_dir / "20260326.md").write_text("latest", encoding="utf-8") - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - } - ) - result = runner.invoke(call_app, ["routines", "output", "routine-1"]) - assert result.exit_code == 0 - assert "latest" in result.stdout - - -class TestSuggestions: - def test_suggestions_read_default(self, journal_path): - save_config({}) - result = runner.invoke(call_app, ["routines", "suggestions"]) - assert result.exit_code == 0 - assert "enabled" in result.stdout - - def test_suggestions_disable(self, journal_path): - save_config({}) - result = runner.invoke(call_app, ["routines", "suggestions", "--disable"]) - assert result.exit_code == 0 - assert "disabled" in result.stdout - config = get_config() - assert config["_meta"]["suggestions_enabled"] is False - - def test_suggestions_enable(self, journal_path): - save_config({"_meta": {"suggestions_enabled": False}}) - result = runner.invoke(call_app, ["routines", "suggestions", "--enable"]) - assert result.exit_code == 0 - assert "enabled" in result.stdout - config = get_config() - assert config["_meta"]["suggestions_enabled"] is True - - -class TestTriggerCounting: - """Test trigger counting for progressive discovery.""" - - def test_morning_briefing_triggers(self, journal_path): - """Calendar queries increment morning-briefing trigger count.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - module._count_triggers("what's on my calendar today", None, config) - module._count_triggers("show me my schedule", None, config) - module._count_triggers("what's my agenda", None, config) - - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["trigger_count"] == 3 - assert entry["first_trigger"] is not None - assert entry["last_trigger"] is not None - - def test_relationship_pulse_triggers(self, journal_path): - """Relationship queries increment relationship-pulse trigger count.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - module._count_triggers("who haven't i talked to recently", None, config) - module._count_triggers("when did i last talk to Sarah", None, config) - - entry = config["_meta"]["suggestions"]["relationship-pulse"] - assert entry["trigger_count"] == 2 - - def test_commitment_audit_triggers(self, journal_path): - """Commitment queries increment commitment-audit trigger count.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - module._count_triggers("do I have any overdue follow-ups", None, config) - module._count_triggers("what commitments have I dropped", None, config) - - entry = config["_meta"]["suggestions"]["commitment-audit"] - assert entry["trigger_count"] == 2 - - def test_domain_watch_requires_facet(self, journal_path): - """domain-watch triggers only count when facet is present.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - module._count_triggers("track this trend over time", None, config) - assert "domain-watch" not in config["_meta"]["suggestions"] - - module._count_triggers("track this trend over time", "work", config) - entry = config["_meta"]["suggestions"]["domain-watch"] - assert entry["trigger_count"] == 1 - assert entry["trigger_data"]["topics"]["work"] == [date.today().isoformat()] - - def test_domain_watch_dedupes_same_day(self, journal_path): - """domain-watch only counts distinct dates per topic.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - module._count_triggers("track trends lately", "work", config) - changed = module._count_triggers("watch these trends", "work", config) - assert not changed - - entry = config["_meta"]["suggestions"]["domain-watch"] - assert entry["trigger_count"] == 1 - - def test_no_match_no_mutation(self, journal_path): - """Messages that don't match any pattern don't mutate config.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - changed = module._count_triggers("hello how are you", None, config) - assert not changed - assert config["_meta"]["suggestions"] == {} - - def test_write_avoidance(self, journal_path): - """_count_triggers returns False when no triggers matched.""" - module = _load_chat_context_module() - config = {"_meta": {"suggestions": {}}} - - assert module._count_triggers("just chatting", None, config) is False - assert module._count_triggers("what's on my calendar", None, config) is True - - -class TestEligibilityGates: - """Test the 5-gate eligibility chain for routine suggestions.""" - - def test_suggestions_disabled_blocks(self, journal_path): - """Gate 1: suggestions_enabled=False blocks all suggestions.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions_enabled": False, - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - } - journal_config = {"agent": {"name_status": "chosen"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_naming_default_blocks(self, journal_path): - """Gate 2: name_status='default' blocks all suggestions.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - } - } - journal_config = {"agent": {"name_status": "default"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_active_routine_blocks(self, journal_path): - """Gate 3: existing routine with same template blocks suggestion.""" - module = _load_routine_context_module() - routines_config = { - "routine-1": { - "id": "routine-1", - "name": "Morning Briefing", - "template": "morning-briefing", - }, - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - }, - } - journal_config = {"agent": {"name_status": "chosen"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_declined_blocks(self, journal_path): - """Gate 4: declined response blocks suggestion for that template.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": "declined", - "suggested": True, - } - } - } - } - journal_config = {"agent": {"name_status": "chosen"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_cooldown_blocks(self, journal_path): - """Gate 5: suggestion within last 7 days blocks all.""" - module = _load_routine_context_module() - yesterday = (date.today() - timedelta(days=1)).isoformat() - routines_config = { - "_meta": { - "last_suggestion_date": yesterday, - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - } - journal_config = {"agent": {"name_status": "chosen"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_all_gates_pass(self, journal_path): - """When all gates pass and threshold met, returns suggestion.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - } - } - journal_config = {"agent": {"name_status": "chosen"}} - result = module.get_eligible_suggestion(routines_config, journal_config) - assert result is not None - assert result["template_name"] == "morning-briefing" - assert result["trigger_count"] == 3 - - def test_below_threshold_no_suggestion(self, journal_path): - """Trigger count below threshold does not produce a suggestion.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 2, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - } - } - journal_config = {"agent": {"name_status": "chosen"}} - assert module.get_eligible_suggestion(routines_config, journal_config) is None - - def test_highest_trigger_count_wins(self, journal_path): - """When multiple templates eligible, highest trigger_count wins.""" - module = _load_routine_context_module() - routines_config = { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - }, - "meeting-prep": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - }, - } - } - } - journal_config = {"agent": {"name_status": "chosen"}} - result = module.get_eligible_suggestion(routines_config, journal_config) - assert result["template_name"] == "meeting-prep" - assert result["trigger_count"] == 5 - - def test_cooldown_expired_allows(self, journal_path): - """Cooldown older than 7 days allows suggestions.""" - module = _load_routine_context_module() - old_date = (date.today() - timedelta(days=8)).isoformat() - routines_config = { - "_meta": { - "last_suggestion_date": old_date, - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - }, - } - } - journal_config = {"agent": {"name_status": "chosen"}} - result = module.get_eligible_suggestion(routines_config, journal_config) - assert result is not None - - -class TestSuggestRespond: - """Test suggest-respond and suggest-state CLI commands.""" - - def test_suggest_respond_accepted(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - } - } - ) - result = runner.invoke( - module.app, - ["suggest-respond", "morning-briefing", "--accepted"], - ) - assert result.exit_code == 0 - assert "accepted" in result.output - - config = get_config() - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["response"] == "accepted" - assert entry["suggested"] is True - assert config["_meta"]["last_suggestion_date"] is not None - - def test_suggest_respond_declined(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": None, - "suggested": False, - } - } - } - } - ) - result = runner.invoke( - module.app, - ["suggest-respond", "morning-briefing", "--declined"], - ) - assert result.exit_code == 0 - assert "declined" in result.output - - config = get_config() - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["response"] == "declined" - - def test_suggest_respond_no_flags_fails(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "response": None, - "suggested": False, - } - } - } - } - ) - result = runner.invoke( - module.app, - ["suggest-respond", "morning-briefing"], - ) - assert result.exit_code == 1 - - def test_suggest_respond_both_flags_fails(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "response": None, - "suggested": False, - } - } - } - } - ) - result = runner.invoke( - module.app, - [ - "suggest-respond", - "morning-briefing", - "--accepted", - "--declined", - ], - ) - assert result.exit_code == 1 - - def test_suggest_respond_unknown_template_fails(self, journal_path): - module = _load_routines_cli_module() - save_config({"_meta": {"suggestions": {}}}) - result = runner.invoke( - module.app, - ["suggest-respond", "nonexistent", "--accepted"], - ) - assert result.exit_code == 1 - - def test_suggest_state(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 3, - "response": "accepted", - } - } - } - } - ) - result = runner.invoke(module.app, ["suggest-state"]) - assert result.exit_code == 0 - data = __import__("json").loads(result.output) - assert "morning-briefing" in data - assert data["morning-briefing"]["response"] == "accepted" - - -class TestGetRoutineState: - def test_basic_structure(self, journal_path): - from solstone.think.routines import get_routine_state - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "last_run": None, - } - } - ) - state = get_routine_state() - assert len(state) == 1 - assert state[0]["name"] == "Morning" - assert state[0]["cadence"] == "0 9 * * *" - assert state[0]["enabled"] is True - assert state[0]["output_summary"] is None - - def test_recent_output_summary(self, journal_path): - from solstone.think.routines import get_routine_state - - last_run = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc).isoformat() - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "last_run": last_run, - } - } - ) - output_dir = journal_path / "routines" / "routine-1" - output_dir.mkdir(parents=True) - (output_dir / "20260327.md").write_text( - "Here is the morning briefing summary for today.", encoding="utf-8" - ) - - dt = datetime(2026, 3, 27, 10, 0, tzinfo=timezone.utc) - with _fake_now(dt): - state = get_routine_state() - - assert len(state) == 1 - assert state[0]["output_summary"] is not None - assert "morning briefing" in state[0]["output_summary"] - - def test_meta_excluded(self, journal_path): - from solstone.think.routines import get_routine_state - - save_config( - { - "_meta": {"suggestions_enabled": True}, - "routine-1": { - "id": "routine-1", - "name": "Morning", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "last_run": None, - }, - } - ) - state = get_routine_state() - assert len(state) == 1 - assert state[0]["name"] == "Morning" - - def test_paused_until(self, journal_path): - from solstone.think.routines import get_routine_state - - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": False, - "facets": [], - "last_run": None, - "resume_date": "2026-04-01", - } - } - ) - state = get_routine_state() - assert state[0]["paused_until"] == "2026-04-01" - assert state[0]["enabled"] is False - - -class TestMetaFiltering: - def test_list_excludes_meta(self, journal_path): - save_config( - { - "_meta": {"suggestions_enabled": True}, - "routine-1": { - "id": "routine-1", - "name": "Test", - "instruction": "test", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - }, - } - ) - result = runner.invoke(call_app, ["routines", "list"]) - assert result.exit_code == 0 - assert "Test" in result.stdout - assert "_meta" not in result.stdout - assert "suggestions" not in result.stdout - - def test_check_skips_meta(self, journal_path): - import solstone.think.routines as mod - - save_config( - { - "_meta": {"suggestions_enabled": True}, - "routine-1": { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "0 9 * * *", - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - }, - } - ) - - dt = datetime(2026, 3, 27, 9, 0, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_called_once() - - -class TestDeleteSuggestionReset: - """Test that delete resets accepted suggestion state but preserves declined.""" - - def test_delete_resets_accepted_suggestion(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning Briefing", - "template": "morning-briefing", - "cadence": "0 7 * * *", - "enabled": True, - }, - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": "accepted", - "suggested": True, - } - } - }, - } - ) - result = runner.invoke(module.app, ["delete", "routine-1"]) - assert result.exit_code == 0 - - config = get_config() - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["trigger_count"] == 0 - assert entry["response"] is None - assert entry["suggested"] is False - assert entry["first_trigger"] is None - - def test_delete_preserves_declined_suggestion(self, journal_path): - module = _load_routines_cli_module() - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Morning Briefing", - "template": "morning-briefing", - "cadence": "0 7 * * *", - "enabled": True, - }, - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "first_trigger": "2026-03-01", - "last_trigger": "2026-03-27", - "trigger_data": {}, - "response": "declined", - "suggested": True, - } - } - }, - } - ) - result = runner.invoke(module.app, ["delete", "routine-1"]) - assert result.exit_code == 0 - - config = get_config() - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["response"] == "declined" - assert entry["trigger_count"] == 5 - - def test_delete_no_template_no_reset(self, journal_path): - """Routines without a template field don't touch suggestion state.""" - module = _load_routines_cli_module() - save_config( - { - "routine-1": { - "id": "routine-1", - "name": "Custom Routine", - "cadence": "0 7 * * *", - "enabled": True, - }, - "_meta": { - "suggestions": { - "morning-briefing": { - "trigger_count": 5, - "response": "accepted", - "suggested": True, - } - } - }, - } - ) - result = runner.invoke(module.app, ["delete", "routine-1"]) - assert result.exit_code == 0 - - config = get_config() - entry = config["_meta"]["suggestions"]["morning-briefing"] - assert entry["response"] == "accepted" - assert entry["trigger_count"] == 5 - - -class TestActivityAnticipation: - @staticmethod - def _make_routine(routine_id: str, offset_minutes: int) -> dict: - return { - "id": routine_id, - "name": "Meeting prep", - "instruction": "Prepare for the upcoming activity.", - "cadence": { - "type": "activity-anticipation", - "offset_minutes": offset_minutes, - }, - "timezone": "UTC", - "enabled": True, - "facets": [], - "template": None, - "notify": False, - "last_run": None, - } - - @staticmethod - def _make_anticipated_record( - activity_id: str, - start: str, - title: str = "Sync", - description: str = "Discuss current status.", - participation=None, - *, - facet: str = "work", - ) -> dict: - return { - "id": activity_id, - "activity": "meeting", - "target_date": "2026-04-18", - "start": start, - "end": "10:30:00", - "title": title, - "description": description, - "details": "Review open items.", - "facet": facet, - "source": "anticipated", - "participation": participation or [], - "hidden": False, - } - - @staticmethod - def _seed_activity_record(facet: str, day: str, record: dict) -> None: - from solstone.think.activities import append_activity_record - from solstone.think.facets import create_facet - - title = " ".join(part.capitalize() for part in facet.split("-")) - slug = create_facet(title) - assert slug == facet - written = append_activity_record(facet, day, record) - assert written is True - - def test_dispatch_fires_and_injects_prompt(self, journal_path): - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", -30)}) - self._seed_activity_record( - "work", - "20260418", - self._make_anticipated_record( - "anticipated_meeting_100000_0418", - "10:00:00", - title="Roadmap Sync", - description="Discuss Q2 roadmap.", - participation=[ - {"role": "attendee", "name": "Alex Rivera"}, - {"role": "attendee", "name": "Jordan Lee"}, - {"role": "organizer", "name": "Morgan Shaw"}, - ], - ), - ) - - dt = datetime(2026, 4, 18, 9, 30, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_called_once() - prompt = mock_req.call_args.kwargs["prompt"] - assert prompt.index("## Upcoming Activity") < prompt.index( - "Execute this routine now." - ) - assert "Roadmap Sync" in prompt - assert "10:00:00" in prompt - assert "Discuss Q2 roadmap." in prompt - assert "Alex Rivera" in prompt - assert "Jordan Lee" in prompt - assert "Morgan Shaw" not in prompt - - def test_same_minute_fires_only_once(self, journal_path): - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", -30)}) - self._seed_activity_record( - "work", - "20260418", - self._make_anticipated_record( - "anticipated_meeting_100000_0418", - "10:00:00", - ), - ) - - dt = datetime(2026, 4, 18, 9, 30, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - mod.check() - - assert mock_req.call_count == 1 - - def test_hidden_records_are_skipped(self, journal_path): - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", -30)}) - record = self._make_anticipated_record( - "anticipated_meeting_100000_0418", - "10:00:00", - ) - record["hidden"] = True - self._seed_activity_record("work", "20260418", record) - - dt = datetime(2026, 4, 18, 9, 30, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_not_called() - - def test_non_anticipated_records_are_skipped(self, journal_path): - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", -30)}) - record = self._make_anticipated_record( - "anticipated_meeting_100000_0418", - "10:00:00", - ) - record["source"] = "completed" - self._seed_activity_record("work", "20260418", record) - - dt = datetime(2026, 4, 18, 9, 30, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - mock_req.assert_not_called() - - def test_late_evening_fires_for_next_day_activity(self, journal_path): - """Pre-alert for a 00:15 activity on D+1 must fire at 23:45 on D.""" - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", -30)}) - record = self._make_anticipated_record( - "anticipated_meeting_001500_0419", - "00:15", - ) - record["target_date"] = "2026-04-19" - self._seed_activity_record("work", "20260419", record) - - dt = datetime(2026, 4, 18, 23, 45, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - mod.check() - - assert mock_req.call_count == 1 - - def test_early_morning_fires_for_previous_day_activity(self, journal_path): - """Post-start anticipation for a 23:45 activity on D-1 fires at 00:15 on D.""" - import solstone.think.routines as mod - - save_config({"routine-1": self._make_routine("routine-1", 30)}) - record = self._make_anticipated_record( - "anticipated_meeting_234500_0417", - "23:45", - ) - record["target_date"] = "2026-04-17" - self._seed_activity_record("work", "20260417", record) - - dt = datetime(2026, 4, 18, 0, 15, tzinfo=timezone.utc) - with ( - patch( - "solstone.think.routines.cortex_request", return_value="fake_agent_id" - ) as mock_req, - patch( - "solstone.think.routines.wait_for_uses", - return_value=({"fake_agent_id": "finish"}, []), - ), - patch("solstone.think.routines.callosum_send", return_value=True), - _fake_now(dt), - ): - mod.check() - - assert mock_req.call_count == 1 - - -class TestAccessTier: - def _make_routine(self, **overrides): - routine = { - "id": "routine-1", - "name": "Morning", - "instruction": "Do the thing", - "cadence": "manual", - "facets": [], - "template": None, - "notify": False, - } - routine.update(overrides) - return routine - - def test_resolve_defaults_to_normal_when_absent(self): - assert routines.resolve_routine_access_tier({"id": "x"}) == "normal" - - def test_resolve_returns_raw_declared_value(self): - assert ( - routines.resolve_routine_access_tier({"access_tier": "outbound"}) - == "outbound" - ) - assert routines.resolve_routine_access_tier({"access_tier": None}) is None - - def test_normal_surface_is_normal_via_contract(self): - from solstone.think.cogitate_contract import ( - AccessCapabilities, - capabilities_for_access_tier, - ) - - assert capabilities_for_access_tier("normal") == AccessCapabilities( - sol=True, reads=True, submit=False - ) - - def test_routine_talent_resolves_to_normal(self): - from solstone.think.talent import _validate_access_tier - - path = ( - Path(__file__).resolve().parents[1] / "solstone" / "talent" / "routine.md" - ) - post = frontmatter.load(path) - assert ( - _validate_access_tier( - post.metadata.get("access_tier"), - post.metadata.get("type"), - "access_tier", - ) - == "normal" - ) - - def test_default_and_explicit_normal_dispatch_identically( - self, journal_path, monkeypatch - ): - calls = [] - started = [] - - def fake_cortex_request(**kwargs): - calls.append(kwargs) - return "agent-x" - - def fake_callosum_send(tract, event, **fields): - if event == "started": - started.append((tract, fields)) - return True - - monkeypatch.setattr(routines, "cortex_request", fake_cortex_request) - monkeypatch.setattr(routines, "callosum_send", fake_callosum_send) - monkeypatch.setattr( - routines, "wait_for_uses", lambda *a, **k: ({"agent-x": "finish"}, []) - ) - - assert routines._run_routine(self._make_routine()) is None - assert routines._run_routine(self._make_routine(access_tier="normal")) is None - - assert len(calls) == 2 - assert len(started) == 2 - for kw in calls: - assert kw["name"] == "routine" - assert set(kw["config"].keys()) == {"output_path", "output"} - assert kw["config"]["output"] == "md" - assert "access_tier" not in json.dumps(kw) - # absent and explicit-normal dispatch byte-for-byte identically - assert calls[0]["config"] == calls[1]["config"] - - @pytest.mark.parametrize( - "tier", - [ - "system-read", - "outbound", - "code-agent", - "elevated", - "", - None, - ["outbound"], - 5, - ], - ) - def test_over_declaration_blocks_dispatch_and_records_all_surfaces( - self, tier, journal_path, monkeypatch - ): - cortex_calls = [] - events = [] - - def fake_cortex_request(**kwargs): - cortex_calls.append(kwargs) - return "agent-x" - - def fake_callosum_send(tract, event, **fields): - events.append((tract, event, fields)) - return True - - monkeypatch.setattr(routines, "cortex_request", fake_cortex_request) - monkeypatch.setattr(routines, "wait_for_uses", lambda *a, **k: ({}, [])) - monkeypatch.setattr(routines, "callosum_send", fake_callosum_send) - - reason = routines._run_routine(self._make_routine(access_tier=tier)) - - # dispatch provably never happened - assert cortex_calls == [] - assert all(event != "started" for _t, event, _f in events) - assert reason is not None - - # surface 1: health log line names the tier failure - log_text = (journal_path / "health" / "routines.log").read_text() - expected_token = ( - tier - if (isinstance(tier, str) and tier and not any(c.isspace() for c in tier)) - else "malformed" - ) - assert f"outcome=error-access-tier-{expected_token}" in log_text - - # surface 2: complete event with outcome=error + tier-naming reason - complete = [f for _t, e, f in events if e == "complete"] - assert len(complete) == 1 - assert complete[0]["outcome"] == "error" - assert "access_tier" in complete[0]["reason"] - - # surface 3: output file is a self-evident non-run marker naming the routine - out_files = sorted((journal_path / "routines" / "routine-1").glob("*.md")) - assert len(out_files) == 1 - marker = out_files[0].read_text() - assert "did not run" in marker.lower() - assert "was not executed" in marker - assert "Morning" in marker - - def test_manual_run_surfaces_tier_failure_to_terminal( - self, journal_path, monkeypatch - ): - cortex_calls = [] - - def fake_cortex_request(**kwargs): - cortex_calls.append(kwargs) - return "agent-x" - - monkeypatch.setattr(routines, "cortex_request", fake_cortex_request) - monkeypatch.setattr(routines, "wait_for_uses", lambda *a, **k: ({}, [])) - monkeypatch.setattr(routines, "callosum_send", lambda *a, **k: True) - - save_config({"routine-1": self._make_routine(access_tier="outbound")}) - result = runner.invoke(call_app, ["routines", "run", "routine-1"]) - - assert result.exit_code == 1 - assert "outbound" in result.output - assert cortex_calls == [] diff --git a/tests/test_talent_cli.py b/tests/test_talent_cli.py index 1c8438813..41f115572 100644 --- a/tests/test_talent_cli.py +++ b/tests/test_talent_cli.py @@ -448,7 +448,6 @@ def test_scan_command_examples_dedupes_and_caps(): body = "\n".join( [ "`sol call support search foo`", - "`journal routines list`", "`sol call support search foo`", "`journal talent logs --daily -c 20 --errors.`", "`journal identity pulse --write --value x`", @@ -457,8 +456,8 @@ def test_scan_command_examples_dedupes_and_caps(): assert _scan_command_examples(body, cap=3) == [ "sol call support search foo", - "journal routines list", "journal talent logs --daily -c 20 --errors", + "journal identity pulse --write --value x", ] diff --git a/tests/test_talent_fallback.py b/tests/test_talent_fallback.py index ccf89bed4..a85a80ecf 100644 --- a/tests/test_talent_fallback.py +++ b/tests/test_talent_fallback.py @@ -235,6 +235,7 @@ def _patch_prepare_config_dependencies(monkeypatch): "solstone.think.models.resolve_provider", lambda _context, _type: ("google", "gemini-3-flash-preview"), ) + monkeypatch.setattr("solstone.think.models.get_context_registry", lambda: {}) def test_prepare_config_rejects_frontmatter_outbound_approval(tmp_path, monkeypatch): -- 2.51.2