diff --git a/docs/APPS.md b/docs/APPS.md index 8c90d9d91..fa93ca044 100644 --- a/docs/APPS.md +++ b/docs/APPS.md @@ -106,7 +106,7 @@ The workspace template is included inside the app container (`app.html`). **Reference implementations:** - Minimal: `solstone/apps/home/workspace.html` (simple content) - Styled: `solstone/apps/support/workspace.html` (custom CSS, forms, interactive JS) -- Data-driven: `solstone/apps/todos/workspace.html` (facet sections, dynamic rendering) +- Data-driven: `solstone/apps/entities/workspace.html` (facet sections, dynamic rendering) --- @@ -127,9 +127,9 @@ Define custom routes for your app (API endpoints, form handlers, navigation rout **Reference implementations:** - API endpoints: `solstone/apps/search/routes.py` (search APIs, no index route) -- Form handlers: `solstone/apps/todos/routes.py` (POST handlers, validation, flash messages) +- JSON form/API handlers: `solstone/apps/entities/routes.py` (POST handlers, validation, JSON errors) - Navigation: `solstone/apps/activities/routes.py` (date-based routes with custom context) -- Redirects: `solstone/apps/todos/routes.py` index route (redirects `/` to today's date) +- Redirects: `solstone/apps/activities/routes.py` index route (redirects `/` to today's date) @@ -206,7 +206,7 @@ Both badge types appear as red notification counts. icon: '๐Ÿ“ฌ', // Emoji icon (optional) title: 'New Message', // Title (required) message: 'You have...', // Message body (optional) - action: '/app/todos', // Click action URL (optional) + action: '/app/entities', // Click action URL (optional) facet: 'work', // Auto-select facet on click (optional) badge: 5, // Badge count (optional) dismissible: true, // Show X button (default: true) @@ -221,7 +221,7 @@ Both badge types appear as red notification counts. - See [CALLOSUM.md](CALLOSUM.md) for event protocol details **Reference implementations:** -- `solstone/apps/todos/background.html` - App icon badge with API fetch +- `solstone/apps/timeline/background.html` - App icon badge with API fetch **Implementation source:** `solstone/convey/static/app.js` - AppServices framework, `solstone/convey/static/websocket.js` - WebSocket API @@ -237,8 +237,7 @@ Define plain callable tool functions for your app in `tools.py`. - Put shared logic in your app/module layer and call it from these functions **Reference implementations:** -- `solstone/apps/todos/tools.py` -- `solstone/apps/entities/tools.py` +- `solstone/apps/support/tools.py` --- @@ -251,7 +250,7 @@ Define CLI commands for your app that are automatically discovered and available - Export an `app = typer.Typer()` instance with commands defined via `@app.command()` - Automatically discovered and mounted at startup - Errors in one app's CLI don't prevent other apps from loading -- CLI commands call the same data layer as `tools.py` but print formatted console output +- CLI commands call the same app/data layer as routes or tool functions, but print formatted console output **Required export:** ```python @@ -271,7 +270,7 @@ app = typer.Typer(help="Description of your app commands.") **Reference implementations:** - Discovery logic: `solstone/think/call.py` - `_discover_app_calls()` function -- App CLI example: `solstone/apps/todos/call.py` - Todo list command +- App CLI example: `solstone/apps/entities/call.py` - Entity search command **Entities app reference:** `solstone/apps/entities/call.py` is the current pattern for a data-backed app CLI. It exposes `sol call entities list|detect|attach|update|aka|merge|observe|observations|consolidate|move`, and โ€” like every journal-data `call.py` โ€” reaches the journal only over HTTP via the Convey client (`solstone.think.convey_client`), importing no journal/domain module and doing no filesystem I/O of its own. The think-side write-owners it ultimately drives live under `solstone/think/entities/` (e.g. `journal.py`, `saving.py`, `merge.py`, `relationships.py`, `consolidation.py`), which own `journal/entities//entity.json` and the per-entity `.npz` embedding files. @@ -511,14 +510,13 @@ Apps can include their own tests that are discovered and run separately from cor **Directory structure:** ``` apps/my_app/tests/ -โ”œโ”€โ”€ __init__.py โ”œโ”€โ”€ conftest.py # Self-contained fixtures โ””โ”€โ”€ test_*.py # Test files ``` **Reference implementations:** -- Fixture patterns: `solstone/apps/todos/tests/conftest.py` -- Tool testing: `solstone/apps/todos/tests/test_tools.py` +- Fixture patterns: `solstone/apps/entities/tests/conftest.py` +- Route testing: `solstone/apps/entities/tests/test_routes.py` --- @@ -597,12 +595,12 @@ from solstone.apps.utils import log_app_action **Parameters:** - `app` - App name where action originated - `facet` - Facet where action occurred, or `None` for journal-level actions -- `action` - Action type using `{domain}_{verb}` naming (e.g., `entity_add`, `todo_complete`) +- `action` - Action type using `{domain}_{verb}` naming (e.g., `entity_attach`, `activity_update`) - `params` - Action-specific parameters dict - `day` - Optional day in YYYYMMDD format (defaults to today) **Facet-scoped vs journal-level:** -- Pass a facet name for facet-specific actions (todos, entities, etc.) +- Pass a facet name for facet-specific actions (activities, entities, etc.) - Pass `facet=None` for journal-level actions (settings, observers, etc.) Log after successful mutations, not attempts. @@ -616,11 +614,6 @@ Available functions from the `think` module: ### Facets `solstone/think/facets.py`: `get_facets()` - Returns dict of facet configurations -### Todos -`solstone/apps/todos/todo.py`: -- `get_todos(day, facet)` - Get todo list for day and facet -- `TodoChecklist` class - Load and manipulate todo markdown files - ### Entities `solstone/think/entities/`: `load_entities(facet)` - Load entities for a facet @@ -720,7 +713,7 @@ def handle_action(): **See:** `solstone/convey/static/app.css` for implementation details **Examples:** -- Standard: `solstone/apps/home/workspace.html`, `solstone/apps/todos/workspace.html`, `solstone/apps/entities/workspace.html` +- Standard: `solstone/apps/home/workspace.html`, `solstone/apps/entities/workspace.html`, `solstone/apps/activities/workspace.html` - Wide: `solstone/apps/search/workspace.html`, `solstone/apps/activities/_day.html`, `solstone/apps/import/workspace.html` ### CSS Variables @@ -752,16 +745,16 @@ Main stylesheet `solstone/convey/static/app.css` provides base components. Revie ## Common Patterns ### Date-Based Navigation -See `solstone/apps/todos/routes.py:todos_day()` - Shows date validation and `format_date()` usage. Day navigation is handled automatically by the date_nav component. +See `solstone/apps/activities/routes.py` - Shows date validation and `format_date()` usage. Day navigation is handled automatically by the date_nav component. ### AJAX Endpoints -See `solstone/apps/todos/routes.py:move_todo()` - Shows JSON parsing, validation, `error_response()`, `success_response()`. +See `solstone/apps/activities/routes.py` - Shows JSON parsing, validation, `error_response()`, `success_response()`. -### Form Handling with Flash Messages -See `solstone/apps/todos/routes.py:todos_day()` POST handler - Shows form processing, validation, flash messages, redirects. +### POST Handling and Validation +See `solstone/apps/entities/routes.py` POST handlers - Shows request parsing, validation, JSON errors, and success responses. ### Facet-Aware Queries -See `solstone/apps/todos/routes.py:todos_day()` - Loads data per-facet when selected, or all facets when null. +See `solstone/apps/entities/routes.py` - Loads data per-facet when selected, or all facets when null. ### Facet Pill Badges Pass `facet_counts` dict to `render_template()` to show initial badge counts on facet pills: @@ -769,9 +762,9 @@ Pass `facet_counts` dict to `render_template()` to show initial badge counts on facet_counts = {"work": 5, "personal": 3} return render_template("app.html", facet_counts=facet_counts) ``` -For client-side updates (e.g., after completing a todo), use `AppServices.badges.facet.set(facetName, count)`. +For client-side updates, use `AppServices.badges.facet.set(facetName, count)`. -See `solstone/apps/todos/routes.py:todos_day()` - Computes pending counts from already-loaded data. +Apps with per-facet counts should compute them from already-loaded data before rendering, or update them client-side as data changes. --- @@ -800,7 +793,7 @@ FLASK_DEBUG=1 convey ### Logging -Use `current_app.logger` from Flask for debugging. See `solstone/apps/todos/routes.py` for examples. +Use a module-level `logging.getLogger(__name__)` logger for debugging. See `solstone/apps/entities/routes.py` for examples. --- @@ -826,7 +819,7 @@ Browse `solstone/apps/*/` directories for reference implementations. Apps range - **Minimal** - Just `workspace.html` (e.g., `solstone/apps/home/`, `solstone/apps/health/`) - **Styled** - Custom CSS, background services (e.g., `solstone/apps/support/`) -- **Full-featured** - Routes, forms, AJAX, badges, tools (e.g., `solstone/apps/todos/`, `solstone/apps/entities/`) +- **Full-featured** - Routes, forms, AJAX, and badges (e.g., `solstone/apps/entities/`, `solstone/apps/activities/`) --- diff --git a/docs/COGITATE.md b/docs/COGITATE.md index cbff71cdf..220d31726 100644 --- a/docs/COGITATE.md +++ b/docs/COGITATE.md @@ -2,7 +2,7 @@ The canonical contract for **cogitate talents** โ€” the LLM agents Cortex spawns to read and update the journal (chat, pulse, awareness_tender, the entity / -todo / activity / import talents, and the rest). It is the single place a talent +activity / import talents, and the rest). It is the single place a talent author can point at and say what a fresh cogitate run's working directory, context, tools, finalization, and persistence are โ€” instead of reverse-engineering the runtime from `solstone/think/talents.py`, the providers, the policy, and the diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 4137cfd4c..87d1916e2 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -81,9 +81,9 @@ COMMANDS: dict[str, str] = { **Auto-discovered apps** โ€” scans `solstone/apps/*/call.py` at import time: ``` -apps/todos/call.py โ†’ sol call todos ... -apps/activities/call.py โ†’ sol call activities ... apps/entities/call.py โ†’ sol call entities ... +apps/activities/call.py โ†’ sol call activities ... +apps/skills/call.py โ†’ sol call skills ... ``` Each `call.py` must export `app = typer.Typer()`. The directory name becomes the sub-command name. Errors in one app don't prevent others from loading. @@ -371,10 +371,6 @@ solstone/ โ”‚ โ”‚ โ””โ”€โ”€ sol.py # journal identity (built-in) โ”‚ โ””โ”€โ”€ *.py # Top-level command modules โ”œโ”€โ”€ solstone/apps/ -โ”‚ โ”œโ”€โ”€ todos/ -โ”‚ โ”‚ โ”œโ”€โ”€ call.py # sol call todos (auto-discovered) -โ”‚ โ”‚ โ”œโ”€โ”€ todo.py # Data models -โ”‚ โ”‚ โ””โ”€โ”€ talent/todos/SKILL.md # Agent skill doc โ”‚ โ”œโ”€โ”€ activities/ โ”‚ โ”‚ โ”œโ”€โ”€ call.py # sol call activities (auto-discovered) โ”‚ โ”‚ โ””โ”€โ”€ talent/calendar/SKILL.md @@ -421,7 +417,6 @@ solstone/ | App | Source | Commands | |-----|--------|----------| -| `todos` | `solstone/apps/todos/call.py` | list, add, done, cancel, move, upcoming, list-nudges-due, dispatch-nudges | | `activities` | `solstone/apps/activities/call.py` | list, get, create, update, mute, unmute | | `entities` | `solstone/apps/entities/call.py` | list, show, search, observe, merge | | `speakers` | `solstone/apps/speakers/call.py` | list, show, detect-owner, confirm-owner, clusters, suggest | @@ -446,7 +441,7 @@ Skills are documented in `SKILL.md` files and symlinked into both `journal/.clau - `health` and `vit` have skills but no `call.py` - Some call apps provide the CLI while the skill provides agent behavioral context -Skills document the CLI commands but also add behavioral guidance beyond what `--help` shows (e.g., "check upcoming before adding a future todo to avoid duplicates"). +Skills document the CLI commands but also add behavioral guidance beyond what `--help` shows (e.g., "check entity context before attaching a new relationship to avoid duplicates"). ### Keeping skills in sync diff --git a/scripts/check_cogitate_prompts.py b/scripts/check_cogitate_prompts.py index 4269d03a2..7658122ff 100644 --- a/scripts/check_cogitate_prompts.py +++ b/scripts/check_cogitate_prompts.py @@ -82,14 +82,7 @@ _PLACEHOLDER_RE = re.compile(r"<[+A-Za-z0-9_][+A-Za-z0-9_. -]*>") # A false entry (flagging a flag the CLI accepts) is worse than omission. # NOTE: 'facets --json' was a scope seed but is VALID # (sol call facets list-candidates accepts --json, facets/call.py:40) -- omitted. -UNSUPPORTED_FLAGS: list[tuple[tuple[str, ...], str, str]] = [ - # rejected by 'sol call todos list --help' @ HEAD be70b8dc; day is positional - ( - ("todos", "list"), - "-d", - "use the positional day: 'sol call todos list '", - ), -] +UNSUPPORTED_FLAGS: list[tuple[tuple[str, ...], str, str]] = [] ALLOWLIST: dict[tuple[str, str], int] = {} diff --git a/scripts/check_journal_io_access.py b/scripts/check_journal_io_access.py index dea858e14..81a90285c 100644 --- a/scripts/check_journal_io_access.py +++ b/scripts/check_journal_io_access.py @@ -91,7 +91,6 @@ OWNER_FILES: frozenset[str] = frozenset( "solstone/apps/settings/vertex_credentials.py", "solstone/apps/timeline/maintenance.py", "solstone/apps/timeline/talent/segment_summary.py", - "solstone/apps/todos/todo.py", "solstone/apps/import/ingest.py", "solstone/apps/import/facet_ingest.py", "solstone/apps/import/journal_sources.py", diff --git a/solstone/apps/__init__.py b/solstone/apps/__init__.py index f47bcc314..7c929f0d7 100644 --- a/solstone/apps/__init__.py +++ b/solstone/apps/__init__.py @@ -77,7 +77,7 @@ class App: # Hide the universal chat bar on this app app_bar: bool = True - # Allow clicking future dates in month picker (for todos) + # Allow apps to opt into clicking future dates in the month picker. allow_future_dates: bool = False def facets_enabled(self) -> bool: diff --git a/solstone/apps/entities/talent/entity_observer.md b/solstone/apps/entities/talent/entity_observer.md index 66e1762f8..6dab30450 100644 --- a/solstone/apps/entities/talent/entity_observer.md +++ b/solstone/apps/entities/talent/entity_observer.md @@ -27,7 +27,7 @@ Your ONLY mission is entity observation. Nothing else. The context provided may contain information about the journal owner or system status โ€” it is NOT a task list for you. Do not act on any items mentioned there. You must IGNORE operational items from context, including but not limited to: -- Agent failures or agent health issues (todos, newsletters, etc.) +- Agent failures or agent health issues (newsletters, schedulers, etc.) - Entity curation, deduplication, or management (outside of this observation task) - Speaker cluster management or voice identification - Infrastructure issues, Convey errors, or ingest problems diff --git a/solstone/apps/health/talent/health/SKILL.md b/solstone/apps/health/talent/health/SKILL.md index d60a27038..772f95d0f 100644 --- a/solstone/apps/health/talent/health/SKILL.md +++ b/solstone/apps/health/talent/health/SKILL.md @@ -146,7 +146,7 @@ Reference map of key paths. `journal/` is the journal root. | `health/` | Service logs: `.log` symlinks, `callosum.sock`, `supervisor.log` | | `agents/` | Agent run logs: `/.jsonl`, `/_active.jsonl`, `.log` symlink, `.jsonl` day index | | `config/` | `journal.json`, `convey.json`, `schedules.json`, `actions/YYYYMMDD.jsonl` | -| `facets//` | Per-facet data: `facet.json`, `entities/`, `todos/`, `events/`, `news/`, `logs/` | +| `facets//` | Per-facet data: `facet.json`, `entities/`, `events/`, `news/`, `logs/` | | `entities//` | Canonical entity records: `entity.json` | | `tokens/` | Token usage: `YYYYMMDD.jsonl` per day | | `indexer/` | Search index: `journal.sqlite` (FTS5) | diff --git a/solstone/apps/home/needs_you.py b/solstone/apps/home/needs_you.py index 8d1e2bb8f..6c5bbfd49 100644 --- a/solstone/apps/home/needs_you.py +++ b/solstone/apps/home/needs_you.py @@ -35,7 +35,6 @@ class NeedsYouItem: def classify_needs_you( attention: Any, pulse_needs: list[Any], - todos: list[dict[str, Any]], ) -> list[NeedsYouItem]: items: list[NeedsYouItem] = [] @@ -49,11 +48,6 @@ def classify_needs_you( if item is not None: items.append(item) - for todo in todos[:7]: - item = _classify_safely("todo", todo, _classify_todo) - if item is not None: - items.append(item) - return items @@ -88,13 +82,6 @@ def _classify_pulse_need(item: Any) -> NeedsYouItem | None: return _chat_item(text, f"let's dig into {text}") -def _classify_todo(todo: dict[str, Any]) -> NeedsYouItem: - if not isinstance(todo, dict): - raise TypeError("todo must be an object") - text = _require_text(todo.get("text"), "todo text") - return _chat_item(text, f"what's the context on: {text}") - - def _classify_generated_item( item: dict[str, Any], *, diff --git a/solstone/apps/home/routes.py b/solstone/apps/home/routes.py index 4b98261e6..921176cc9 100644 --- a/solstone/apps/home/routes.py +++ b/solstone/apps/home/routes.py @@ -309,28 +309,6 @@ def _load_yesterday_pipeline_summary(yesterday: str) -> dict[str, Any]: return summarize_pipeline_day(yesterday) -def _collect_todos(today: str) -> list[dict[str, Any]]: - """Collect pending todos across all facets.""" - from solstone.apps.todos.todo import get_todos - - todos = [] - try: - facets = get_facets() - except Exception: - logger.warning("home: failed to get facets for todos", exc_info=True) - return [] - - for facet_name in facets: - facet_todos = get_todos(today, facet_name) - if facet_todos is None: - continue - for todo in facet_todos: - if not todo.get("completed") and not todo.get("cancelled"): - todo["facet"] = facet_name - todos.append(todo) - return todos - - def _collect_anticipated_activities(today: str) -> list[dict[str, Any]]: """Collect anticipated activities across all facets.""" from solstone.think.activities import load_activity_records @@ -1154,7 +1132,6 @@ def _build_pulse_context() -> dict[str, Any]: anticipated_activities = _collect_anticipated_activities(today) activities = _collect_activities(today) - todos = _collect_todos(today) routines = _collect_routines() latest_weekly_reflection = _load_latest_weekly_reflection() @@ -1178,7 +1155,6 @@ def _build_pulse_context() -> dict[str, Any]: narrative_content is None and not anticipated_activities and not activities - and not todos and not unseen_routines and not briefing_exists and not attention @@ -1207,7 +1183,7 @@ def _build_pulse_context() -> dict[str, Any]: today_summary_parts.append(f"{n} {'activities' if n != 1 else 'activity'}") today_summary = ", ".join(today_summary_parts) - needs_you_items = classify_needs_you(attention, pulse_needs, todos) + needs_you_items = classify_needs_you(attention, pulse_needs) needs_count = len(needs_you_items) needs_summary = "" if needs_count: @@ -1269,7 +1245,6 @@ def _build_pulse_context() -> dict[str, Any]: "flow_updated_at": flow_updated_at, "anticipated_activities": anticipated_activities, "activities": activities, - "todos": todos, "needs_you_items": [item.to_dict() for item in needs_you_items], "routines": routines, "briefing_sections": briefing_sections, diff --git a/solstone/apps/home/workspace.html b/solstone/apps/home/workspace.html index 583930e52..24d301b11 100644 --- a/solstone/apps/home/workspace.html +++ b/solstone/apps/home/workspace.html @@ -1171,9 +1171,6 @@ {% endif %} {% endfor %} - {% if todos|length > 7 %} - - {% endif %} {% endif %} diff --git a/solstone/apps/reflections/copy.py b/solstone/apps/reflections/copy.py index ffcfd0b2e..ed0ef3aa3 100644 --- a/solstone/apps/reflections/copy.py +++ b/solstone/apps/reflections/copy.py @@ -37,32 +37,31 @@ sources: activities: 4 decisions: 1 followups: 2 - todos: 2 relationship_signals: 2 gaps: [] --- -> Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, 2 todos, and 2 relationship signals. No gaps. +> Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, and 2 relationship signals. No gaps. ## This week -- The week had a boardroom balcony inflection: the secret collaboration moved from furtive sprint energy to a board-approved joint venture, which changed the emotional center of the work from urgency to stewardship (sol://20260308/talents/flow; sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). -- Sunday still sounded like a narrow demo push with two days to prove the idea; by Tuesday the same thread had become a public platform launch with both boards aligned behind it (sol://20260308/talents/flow; sol://facets/verona/events/20260310). +- The week had a boardroom balcony inflection: the secret collaboration moved from furtive sprint energy to a board-approved joint venture, which changed the emotional center of the work from urgency to stewardship (sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). +- Sunday still sounded like a narrow demo push with two days to prove the idea; by Tuesday the same thread had become a public platform launch with both boards aligned behind it (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). ## Follow-ups -- Approval did not clear the deck as much as it reframed it. The remaining work shifted from "can this happen?" to "can this survive contact with a real organization?" through presentation follow-through and team formation (sol://facets/verona/todos/20260308; sol://facets/verona/todos/20260310). -- The open loop is less about winning the room now and more about building enough operating structure to deserve the win you just got (sol://facets/verona/todos/20260310). +- Approval did not clear the deck as much as it reframed it. The remaining work shifted from "can this happen?" to "can this survive contact with a real organization?" through presentation follow-through and team formation (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). +- The open loop is less about winning the room now and more about building enough operating structure to deserve the win you just got (sol://facets/verona/news/20260310). ## Decisions -- The decisive move was to bet on technical proof and coalition-building instead of trying to outmaneuver the political noise around Paris Duke's alternative. That choice shows up both in the Sunday plan and in the approval outcome (sol://20260308/talents/flow; sol://facets/montague/news/20260310). +- The decisive move was to bet on technical proof and coalition-building instead of trying to outmaneuver the political noise around Paris Duke's alternative. That choice shows up both in the Sunday plan and in the approval outcome (sol://facets/verona/events/20260308; sol://facets/montague/news/20260310). ## Relationships -- Juliet shifted from co-conspirator to explicit co-lead. The relationship signal is no longer hidden alignment; it is shared ownership in public, which raises both trust and scrutiny (sol://20260308/talents/flow; sol://facets/verona/news/20260310). +- Juliet shifted from co-conspirator to explicit co-lead. The relationship signal is no longer hidden alignment; it is shared ownership in public, which raises both trust and scrutiny (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). - Friar Lawrence remained the strategic adult in the room: part sponsor, part translator, part stabilizer. His presence on the Sunday strategy call suggests the work needed legitimacy as much as speed (sol://facets/verona/events/20260308). ## Wins - The obvious win is the joint venture approval itself, but the deeper win is that the demo established the Verona Platform as the credible path, not just the romantic one (sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). -- The week also ended with a cleaner story about the work: a platform with real performance numbers, named leaders, and a next chapter that can be staffed instead of improvised (sol://facets/verona/news/20260310; sol://facets/verona/todos/20260310). +- The week also ended with a cleaner story about the work: a platform with real performance numbers, named leaders, and a next chapter that can be staffed instead of improvised (sol://facets/verona/news/20260310; sol://facets/montague/news/20260310). ## Forward look -- The next week should be less about adrenaline and more about operational discipline. Hiring, security, and post-board execution are now the tests that decide whether this was a breakthrough or a beautiful spike (sol://facets/verona/todos/20260310). +- The next week should be less about adrenaline and more about operational discipline. Hiring, security, and post-board execution are now the tests that decide whether this was a breakthrough or a beautiful spike (sol://facets/verona/news/20260310). """ diff --git a/solstone/apps/search/routes.py b/solstone/apps/search/routes.py index 6eeb3b174..93f4f98fd 100644 --- a/solstone/apps/search/routes.py +++ b/solstone/apps/search/routes.py @@ -28,7 +28,6 @@ AGENT_ICONS = { "span": "๐Ÿงต", "audio": "๐ŸŽค", "screen": "๐Ÿ–ฅ๏ธ", - "todo": "โœ…", "entity": "๐Ÿ‘ค", "entity:attached": "๐Ÿ‘ค", "entity:detected": "๐Ÿ‘ค", @@ -45,7 +44,6 @@ AGENT_LABELS = { "span": "Span", "audio": "Transcript", "screen": "Screen", - "todo": "Todo", "entity": "Entity", "entity:attached": "Entity", "entity:detected": "Entity", diff --git a/solstone/apps/todos/_row.html b/solstone/apps/todos/_row.html deleted file mode 100644 index 63fd933d2..000000000 --- a/solstone/apps/todos/_row.html +++ /dev/null @@ -1,66 +0,0 @@ -
  • -
    - - - - -
    - - - -
    - - - - -
    -
  • diff --git a/solstone/apps/todos/app.json b/solstone/apps/todos/app.json deleted file mode 100644 index d64151d2e..000000000 --- a/solstone/apps/todos/app.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "icon": "โœ…", - "label": "todos", - "date_nav": true, - "allow_future_dates": true -} diff --git a/solstone/apps/todos/background.html b/solstone/apps/todos/background.html deleted file mode 100644 index b6617099a..000000000 --- a/solstone/apps/todos/background.html +++ /dev/null @@ -1,78 +0,0 @@ -AppServices.register('todos', { - _nudgeTimers: [], - - initialize() { - AppServices.registerTask('todos', 'update-badge', { - intervalMs: 5 * 60 * 1000, - run: async ({ apiJson }) => { - const data = await apiJson('/app/todos/api/badge-count'); - if (!data || !Number.isFinite(data.count)) { - return undefined; - } - return data.count; - }, - onSuccess: (count) => { - if (count === undefined) { - return; - } - this.updateBadge(count); - } - }); - - AppServices.registerTask('todos', 'check-nudges', { - run: async ({ apiJson }) => { - const data = await apiJson('/app/todos/api/nudges'); - if (!data || !Array.isArray(data.nudges)) { - return undefined; - } - this.checkNudges(data.nudges); - return data.nudges.length; - } - }); - }, - - updateBadge(count) { - if (count > 0) { - AppServices.badges.app.set('todos', count); - } else { - AppServices.badges.app.clear('todos'); - } - }, - - checkNudges(nudges) { - // Clear existing timers - this._nudgeTimers.forEach(t => clearTimeout(t)); - this._nudgeTimers = []; - - const now = Date.now(); - for (const nudge of nudges) { - const nudgeTime = this._parseNudge(nudge.nudge); - const delay = nudgeTime - now; - - if (delay > 0 && delay < 24 * 60 * 60 * 1000) { - const timer = setTimeout(() => { - AppServices.notifications.show({ - app: 'todos', - icon: 'โœ…', - title: 'Todo Reminder', - message: nudge.text, - action: `/app/todos/${nudge.day}`, - facet: nudge.facet, - autoDismiss: 30000, - }); - }, delay); - this._nudgeTimers.push(timer); - } - } - }, - - _parseNudge(nudgeStr) { - // YYYYMMDDTHH:MM -> timestamp - const year = parseInt(nudgeStr.slice(0, 4)); - const month = parseInt(nudgeStr.slice(4, 6)) - 1; - const day = parseInt(nudgeStr.slice(6, 8)); - const hour = parseInt(nudgeStr.slice(9, 11)); - const minute = parseInt(nudgeStr.slice(12, 14)); - return new Date(year, month, day, hour, minute).getTime(); - } -}); diff --git a/solstone/apps/todos/call.py b/solstone/apps/todos/call.py deleted file mode 100644 index be4acd4ad..000000000 --- a/solstone/apps/todos/call.py +++ /dev/null @@ -1,457 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""CLI commands for todo management. - -Auto-discovered by ``think.call`` and mounted as ``sol call todos ...``. -Every verb reaches the journal only over HTTP via the Convey client; this -module imports no journal/domain function and performs no filesystem I/O. -""" - -import json -import os -import subprocess -from datetime import datetime -from typing import Any - -import typer - -from solstone.convey.reasons import ( - INVALID_DAY, - INVALID_REQUEST_VALUE, - OPERATION_NO_LONGER_AVAILABLE, - TODO_BUSY, - TODO_OPERATION_FAILED, -) -from solstone.think.convey_client import ConveyClientError, get_client - -app = typer.Typer(help="Todo checklist management.") - - -def _get_sol_facet() -> str | None: - return os.environ.get("SOL_FACET") or None - - -def _resolve_sol_day(arg: str | None) -> str: - if arg: - return arg - env = os.environ.get("SOL_DAY") or None - if env: - return env - typer.echo("Error: day is required (pass as argument or set SOL_DAY).", err=True) - raise typer.Exit(1) - - -def _resolve_sol_day_or_today(arg: str | None) -> str: - if arg: - return arg - env = os.environ.get("SOL_DAY") or None - if env: - return env - return datetime.now().strftime("%Y%m%d") - - -def _resolve_sol_facet(arg: str | None) -> str: - if arg: - return arg - env = _get_sol_facet() - if env: - return env - typer.echo( - "Error: facet is required (pass as argument or set SOL_FACET).", err=True - ) - raise typer.Exit(1) - - -def _exit_with(message: str) -> None: - typer.echo(message, err=True) - raise typer.Exit(1) - - -def _handle_todo_error( - err: ConveyClientError, - *, - from_facet: str | None = None, - to_facet: str | None = None, - warn_operation_failed: bool = False, -) -> None: - detail = err.detail or "" - if err.reason_code == TODO_BUSY.code: - _exit_with("Error: todo list is busy, try again.") - if err.reason_code == INVALID_REQUEST_VALUE.code and detail: - if from_facet is not None and to_facet is not None: - if detail == from_facet: - _exit_with(f"Error: Facet '{detail}' (--from) does not exist.") - if detail == to_facet: - _exit_with(f"Error: Facet '{detail}' (--to) does not exist.") - _exit_with(f"Error: {detail}") - if err.reason_code in {INVALID_DAY.code, OPERATION_NO_LONGER_AVAILABLE.code}: - if detail: - _exit_with(f"Error: {detail}") - if err.reason_code == TODO_OPERATION_FAILED.code: - if detail and warn_operation_failed: - typer.echo(f"Warning: {detail}", err=True) - raise typer.Exit(1) - if detail: - _exit_with(f"Error: {detail}") - - typer.echo(err.error, err=True) - raise typer.Exit(1) - - -def _request( - method: str, - path: str, - *, - params: dict[str, Any] | None = None, - json_body: dict[str, Any] | None = None, -) -> Any: - return get_client().request(method, path, params=params, json=json_body) - - -def _print_checklist_response(body: dict[str, Any]) -> None: - if body["mode"] == "range": - facet_count = int(body["facet_count"]) - for section in body["sections"]: - if facet_count > 1: - typer.echo(f"## {section['facet']}") - for day_entry in section["days"]: - if day_entry["has_items"]: - typer.echo(f"### {day_entry['day']}") - typer.echo(day_entry["display"]) - typer.echo() - return - - day = body["day"] - facets = body["facets"] - if not facets: - typer.echo(f"No todos found for {day}.") - return - - if len(facets) == 1: - entry = facets[0] - if not entry["has_items"]: - typer.echo(f"No todos found for {day}.") - return - typer.echo(entry["display"]) - return - - for entry in facets: - typer.echo(f"## {entry['facet']}") - if entry["has_items"]: - typer.echo(entry["display"]) - typer.echo() - - -@app.command("list") -def list_todos( - day: str | None = typer.Argument( - None, help="Journal day in YYYYMMDD format (or use --day / SOL_DAY)." - ), - day_opt: str | None = typer.Option( - None, - "--day", - "-d", - help="Journal day (alternative to the positional argument; or set SOL_DAY).", - ), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name. Omit to show all facets." - ), - to: str | None = typer.Option( - None, "--to", help="End day for range query (YYYYMMDD, inclusive)." - ), -) -> None: - """Show the todo checklist for a day (or date range).""" - if day is not None and day_opt is not None and day != day_opt: - _exit_with( - f"Error: conflicting day given as argument ({day}) and --day ({day_opt})." - ) - day = _resolve_sol_day_or_today(day if day is not None else day_opt) - if facet is None: - facet = _get_sol_facet() - - if to is not None and to < day: - _exit_with(f"Error: --to ({to}) must not be before day ({day})") - - params: dict[str, Any] = {"day": day} - if facet is not None: - params["facet"] = facet - if to is not None: - params["to"] = to - - try: - body = _request("GET", "/app/todos/api/checklist", params=params) - except ConveyClientError as err: - _handle_todo_error(err) - _print_checklist_response(body) - - -@app.command("add") -def add_todo( - text: str = typer.Argument(help="Todo item text."), - day: str | None = typer.Option( - None, "--day", "-d", help="Journal day in YYYYMMDD format (or set SOL_DAY)." - ), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), - nudge: str | None = typer.Option( - None, - "--nudge", - "-n", - help="Nudge time: HH:MM, now, tomorrow HH:MM, or YYYYMMDDTHH:MM.", - ), - force: bool = typer.Option( - False, "--force", help="Skip duplicate check and add anyway." - ), -) -> None: - """Add a new todo item.""" - day = _resolve_sol_day(day) - facet = _resolve_sol_facet(facet) - - try: - datetime.strptime(day, "%Y%m%d") - except ValueError: - _exit_with(f"Error: invalid day format '{day}'") - - try: - body = _request( - "POST", - "/app/todos/api/add", - json_body={ - "text": text, - "day": day, - "facet": facet, - "nudge": nudge, - "force": force, - }, - ) - except ConveyClientError as err: - _handle_todo_error(err) - - if body.get("status") == "duplicate": - typer.echo(f"Duplicate detected for: {text}", err=True) - for match in body["matches"]: - typer.echo( - f" [{match['score']:.0f}%] {match['facet']}/{match['day']} " - f"line {match['line']}: {match['text']}", - err=True, - ) - typer.echo("Use --force to add anyway.", err=True) - raise typer.Exit(1) - - typer.echo(body["display"]) - - -@app.command("done") -def done_todo( - line_number: int = typer.Argument(help="1-based line number of the todo."), - day: str | None = typer.Option( - None, "--day", "-d", help="Journal day in YYYYMMDD format (or set SOL_DAY)." - ), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """Mark a todo item as done.""" - day = _resolve_sol_day(day) - facet = _resolve_sol_facet(facet) - - try: - body = _request( - "POST", - "/app/todos/api/done", - json_body={"day": day, "facet": facet, "line_number": line_number}, - ) - except ConveyClientError as err: - _handle_todo_error(err) - typer.echo(body["display"]) - - -@app.command("cancel") -def cancel_todo( - line_number: int = typer.Argument(help="1-based line number of the todo."), - day: str | None = typer.Option( - None, "--day", "-d", help="Journal day in YYYYMMDD format (or set SOL_DAY)." - ), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name (or set SOL_FACET)." - ), -) -> None: - """Cancel a todo item.""" - day = _resolve_sol_day(day) - facet = _resolve_sol_facet(facet) - - try: - body = _request( - "POST", - "/app/todos/api/cancel", - json_body={"day": day, "facet": facet, "line_number": line_number}, - ) - except ConveyClientError as err: - _handle_todo_error(err) - typer.echo(body["display"]) - - -@app.command("move") -def move_todo( - line_number: int = typer.Argument( - help="Line number of the todo to move (1-indexed)." - ), - day: str = typer.Option(..., "--day", help="Day in YYYYMMDD format."), - from_facet: str = typer.Option(..., "--from", help="Source facet."), - to_facet: str = typer.Option(..., "--to", help="Destination facet."), - consent: bool = typer.Option( - False, - "--consent", - help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", - ), -) -> None: - """Move an open todo from one facet to another.""" - try: - body = _request( - "POST", - "/app/todos/api/move", - json_body={ - "day": day, - "from_facet": from_facet, - "to_facet": to_facet, - "line_number": line_number, - "consent": consent, - }, - ) - except ConveyClientError as err: - _handle_todo_error( - err, - from_facet=from_facet, - to_facet=to_facet, - warn_operation_failed=True, - ) - - cancelled = body["cancelled"] - typer.echo( - f"Moved todo {line_number} ('{cancelled['text']}') " - f"from '{from_facet}' to '{to_facet}'." - ) - - -@app.command("upcoming") -def upcoming_todos( - limit: int = typer.Option(20, "--limit", "-l", help="Maximum number of todos."), - facet: str | None = typer.Option( - None, "--facet", "-f", help="Facet name. Omit to show all facets." - ), -) -> None: - """Show upcoming todos across future days.""" - if facet is None: - facet = _get_sol_facet() - - params: dict[str, Any] = {"limit": limit} - if facet is not None: - params["facet"] = facet - try: - body = _request("GET", "/app/todos/api/upcoming", params=params) - except ConveyClientError as err: - _handle_todo_error(err) - typer.echo(body["upcoming"]) - - -@app.command("list-nudges-due") -def list_nudges_due( - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET). Omit to check all facets.", - ), - json_output: bool = typer.Option(False, "--json", help="Output as JSON."), -) -> None: - """List due, unnotified todo nudges.""" - params: dict[str, Any] = {} - if facet is not None: - params["facet"] = facet - try: - body = _request("GET", "/app/todos/api/nudges-due", params=params) - except ConveyClientError as err: - _handle_todo_error(err) - items = body["items"] - - if json_output: - payload = [ - { - "day": item["day"], - "facet": item["facet"], - "index": item["index"], - "text": item["text"], - "nudge": item["nudge"], - "nudge_display": item["nudge_display"], - } - for item in items - ] - typer.echo(json.dumps(payload, ensure_ascii=False, indent=2)) - return - - if not items: - typer.echo("No nudges due.") - return - - grouped: dict[str, list[dict[str, Any]]] = {} - for item in items: - grouped.setdefault(item["facet"], []).append(item) - - if len(grouped) == 1: - for item in next(iter(grouped.values())): - typer.echo(f"{item['index']}: {item['display_line']}") - return - - for facet_name, facet_items in grouped.items(): - typer.echo(f"## {facet_name}") - for item in facet_items: - typer.echo(f"{item['index']}: {item['display_line']}") - typer.echo() - - -@app.command("dispatch-nudges") -def dispatch_nudges( - facet: str | None = typer.Option( - None, - "--facet", - "-f", - help="Facet name (or set SOL_FACET). Omit to check all facets.", - ), -) -> None: - """Dispatch due, unnotified todo nudges.""" - body: dict[str, Any] - json_body: dict[str, Any] = {} - if facet is not None: - json_body["facet"] = facet - try: - body = _request("POST", "/app/todos/api/dispatch-nudges", json_body=json_body) - except ConveyClientError as err: - _handle_todo_error(err) - - for item in body["items"]: - try: - subprocess.run( - [ - "sol", - "notify", - item["text"], - "--title", - "Todo Reminder", - "--icon", - "โœ…", - "--app", - "todos", - "--facet", - item["facet"], - "--action", - f"/app/todos/{body['day']}", - ], - check=False, - capture_output=True, - ) - except FileNotFoundError: - pass - - typer.echo(f"dispatched {len(body['items'])} nudge(s)") diff --git a/solstone/apps/todos/copy.py b/solstone/apps/todos/copy.py deleted file mode 100644 index f4478a40f..000000000 --- a/solstone/apps/todos/copy.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Owner-facing copy for the todos render-at-scale affordance.""" - -SHOW_MORE_INCOMPLETE = "Show {count} more" -SHOW_MORE_COMPLETED = "Show {count} more completed" -SHOW_MORE_INCOMPLETE_ARIA = "Show {count} more todos in {facet}" -SHOW_MORE_COMPLETED_ARIA = "Show {count} more completed todos in {facet}" diff --git a/solstone/apps/todos/maint/000_time_to_nudge.py b/solstone/apps/todos/maint/000_time_to_nudge.py deleted file mode 100644 index 9937f13a5..000000000 --- a/solstone/apps/todos/maint/000_time_to_nudge.py +++ /dev/null @@ -1,83 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Migrate todo time field to nudge format. - -Scans all facets/*/todos/*.jsonl files and converts any "time" field -to the new "nudge" format (YYYYMMDDTHH:MM), using the filename day. -""" - -import argparse -import json -import logging -from pathlib import Path - -from solstone.think.utils import get_journal, setup_cli - -logger = logging.getLogger(__name__) - - -def main(): - parser = argparse.ArgumentParser(description="Migrate todo time โ†’ nudge") - setup_cli(parser) - - journal = Path(get_journal()) - facets_dir = journal / "facets" - - if not facets_dir.is_dir(): - print("No facets directory found.") - return - - migrated_files = 0 - migrated_entries = 0 - - for facet_dir in sorted(facets_dir.iterdir()): - if not facet_dir.is_dir(): - continue - todos_dir = facet_dir / "todos" - if not todos_dir.is_dir(): - continue - - for jsonl_file in sorted(todos_dir.glob("*.jsonl")): - stem = jsonl_file.stem - if len(stem) != 8 or not stem.isdigit(): - continue - - day = stem - lines = jsonl_file.read_text(encoding="utf-8").splitlines() - new_lines = [] - file_changed = False - - for line in lines: - stripped = line.strip() - if not stripped: - new_lines.append(line) - continue - try: - data = json.loads(stripped) - except json.JSONDecodeError: - new_lines.append(line) - continue - - if "time" in data and "nudge" not in data: - time_val = data.pop("time") - data["nudge"] = f"{day}T{time_val}" - new_lines.append(json.dumps(data, ensure_ascii=False)) - file_changed = True - migrated_entries += 1 - else: - new_lines.append(line) - - if file_changed: - content = "\n".join(new_lines) - if new_lines and not content.endswith("\n"): - content += "\n" - jsonl_file.write_text(content, encoding="utf-8") - migrated_files += 1 - logger.info("Migrated %s", jsonl_file) - - print(f"Migration complete: {migrated_entries} entries in {migrated_files} files.") - - -if __name__ == "__main__": - main() diff --git a/solstone/apps/todos/routes.py b/solstone/apps/todos/routes.py deleted file mode 100644 index 7ff6c35e4..000000000 --- a/solstone/apps/todos/routes.py +++ /dev/null @@ -1,1324 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -from datetime import date, datetime, timedelta -from pathlib import Path -from typing import Any - -from flask import ( - Blueprint, - current_app, - flash, - jsonify, - redirect, - render_template, - request, - url_for, -) - -from solstone.apps.todos import copy as todos_copy -from solstone.apps.todos.todo import ( - TodoChecklist, - TodoEmptyTextError, - TodoError, - TodoItem, - TodoMovePartialError, - TodoNotMovableError, - TodoTerminalStateError, - find_cross_facet_matches, - format_nudge, - get_facets_with_todos, - get_todo_days_in_range, - get_todos, - parse_nudge, - upcoming, - validate_line_number, -) -from solstone.apps.utils import log_app_action -from solstone.convey import state -from solstone.convey.config import get_selected_facet -from solstone.convey.copy import CONVEY_RELOAD_HINT -from solstone.convey.reasons import ( - AGENT_UNAVAILABLE, - INVALID_DAY, - INVALID_MONTH, - INVALID_REQUEST_VALUE, - MISSING_REQUIRED_FIELD, - OPERATION_NO_LONGER_AVAILABLE, - TODO_BUSY, - TODO_OPERATION_FAILED, -) -from solstone.convey.utils import ( - DATE_RE, - created, - error_response, - format_date, - respond_collection, - success_response, -) -from solstone.think.facets import get_facets, log_call_action -from solstone.think.journal_io.errors import LockTimeout - -VISIBLE_INCOMPLETE_BUDGET = 30 -VISIBLE_COMPLETED_BUDGET = 5 - -todos_bp = Blueprint("app:todos", __name__, url_prefix="/app/todos") - - -@todos_bp.app_context_processor -def _inject_todos_copy() -> dict: - return {"todos_copy": todos_copy} - - -def _compute_badge_counts(day: str, facet: str) -> dict: - """Compute badge counts for a specific facet and total for today. - - Returns dict with 'facet_count' and 'total_count'. - Excludes cancelled todos from counts. - """ - today = date.today().strftime("%Y%m%d") - - # Get count for the specific facet (exclude cancelled) - facet_todos = get_todos(day, facet) - facet_count = 0 - if facet_todos: - facet_count = sum( - 1 for t in facet_todos if not t.get("completed") and not t.get("cancelled") - ) - - # Get total count across all facets for today (for app icon badge) - total_count = 0 - if day == today: - try: - facet_map = get_facets() - except Exception: - facet_map = {} - - for facet_name in facet_map.keys(): - todos = get_todos(today, facet_name) - if todos: - total_count += sum( - 1 - for t in todos - if not t.get("completed") and not t.get("cancelled") - ) - - return {"facet_count": facet_count, "total_count": total_count} - - -@todos_bp.route("/api/badge-count") -def badge_count(): - """Get total pending todo count for today across all facets.""" - today = date.today().strftime("%Y%m%d") - total = 0 - - try: - facet_map = get_facets() - except Exception: - facet_map = {} - - for facet_name in facet_map.keys(): - facet_todos = get_todos(today, facet_name) - if facet_todos: - total += sum( - 1 - for todo in facet_todos - if not todo.get("completed") and not todo.get("cancelled") - ) - - return jsonify({"count": total}) - - -@todos_bp.route("/api/nudges") -def nudges_api(): - """Return todos with future nudges for today and tomorrow.""" - now = datetime.now() - today = now.strftime("%Y%m%d") - tomorrow = (now + timedelta(days=1)).strftime("%Y%m%d") - now_str = now.strftime("%Y%m%dT%H:%M") - - nudges = [] - facets_dir = Path(state.journal_root) / "facets" - if not facets_dir.is_dir(): - return jsonify({"nudges": []}) - - for facet_dir in facets_dir.iterdir(): - if not facet_dir.is_dir(): - continue - facet_name = facet_dir.name - for day in [today, tomorrow]: - checklist = TodoChecklist.load(day, facet_name) - if not checklist.exists: - continue - for item in checklist.items: - if ( - item.nudge - and item.nudge > now_str - and not item.completed - and not item.cancelled - and not item.notified - ): - nudges.append( - { - "facet": facet_name, - "day": day, - "index": item.index, - "text": item.text, - "nudge": item.nudge, - } - ) - - return jsonify({"nudges": nudges}) - - -@todos_bp.route("/api/stats/") -def api_stats(month: str): - """Return todo counts per facet for a specific month. - - Args: - month: YYYYMM format month string - - Returns: - JSON dict mapping day (YYYYMMDD) to facet counts dict. - Count is number of non-cancelled todos for that day. - """ - import re - - if not re.fullmatch(r"\d{6}", month): - return error_response( - INVALID_MONTH, - detail="Invalid month format, expected YYYYMM", - ) - - try: - facet_map = get_facets() - except Exception: - facet_map = {} - - stats: dict[str, dict[str, int]] = {} - journal_root = Path(state.journal_root) - - for facet_name in facet_map.keys(): - todos_dir = journal_root / "facets" / facet_name / "todos" - if not todos_dir.exists(): - continue - - for todo_file in todos_dir.glob(f"{month}*.jsonl"): - day = todo_file.stem - if not DATE_RE.fullmatch(day): - continue - - # Count non-cancelled todos in file - facet_todos = get_todos(day, facet_name) - if facet_todos: - count = sum(1 for t in facet_todos if not t.get("cancelled")) - if count > 0: - if day not in stats: - stats[day] = {} - stats[day][facet_name] = count - - return jsonify(stats) - - -def _json_body() -> dict[str, Any]: - payload = request.get_json(silent=True) or {} - return payload if isinstance(payload, dict) else {} - - -def _body_str(payload: dict[str, Any], name: str) -> str | None: - value = payload.get(name) - if value is None: - return None - return str(value) - - -def _required_body_str(payload: dict[str, Any], name: str) -> tuple[str | None, Any]: - value = _body_str(payload, name) - if value is None: - return None, error_response( - MISSING_REQUIRED_FIELD, detail=f"{name} is required" - ) - return value, None - - -def _body_line_number(payload: dict[str, Any]) -> tuple[int | None, Any]: - try: - return int(payload.get("line_number")), None - except (TypeError, ValueError): - return None, error_response( - MISSING_REQUIRED_FIELD, - detail="line_number is required", - ) - - -def _checklist_payload(day: str, facet: str) -> dict[str, Any]: - checklist = TodoChecklist.load(day, facet) - return { - "day": day, - "facet": facet, - "exists": checklist.exists, - "has_items": bool(checklist.items), - "display": checklist.display(), - } - - -def _due_nudge_items( - facet: str | None, -) -> tuple[str, str, datetime, list[dict[str, Any]]]: - now = datetime.now() - today = now.strftime("%Y%m%d") - now_str = now.strftime("%Y%m%dT%H:%M") - - facets_dir = Path(state.journal_root) / "facets" - if not facets_dir.is_dir(): - return today, now_str, now, [] - - if facet is not None: - facet_names = [facet] - else: - facet_names = [d.name for d in facets_dir.iterdir() if d.is_dir()] - - items: list[dict[str, Any]] = [] - for facet_name in facet_names: - checklist = TodoChecklist.load(today, facet_name) - if not checklist.exists: - continue - for item in checklist.items: - if ( - item.nudge - and item.nudge <= now_str - and not item.notified - and not item.completed - and not item.cancelled - ): - items.append( - { - "day": today, - "facet": facet_name, - "index": item.index, - "text": item.text, - "nudge": item.nudge, - "nudge_display": format_nudge(item.nudge, now=now), - "display_line": item.display_line(), - } - ) - return today, now_str, now, items - - -@todos_bp.route("/api/checklist") -def api_checklist(): - """Return rendered todo checklist data for the CLI.""" - day = request.args.get("day", "").strip() - to = request.args.get("to") - to = to.strip() if to is not None else None - facet = request.args.get("facet") - facet = facet.strip() if facet is not None and facet.strip() else None - - if to is not None and to < day: - return error_response( - INVALID_REQUEST_VALUE, - detail=f"--to ({to}) must not be before day ({day})", - ) - - if to is not None and to != day: - facets = [facet] if facet else sorted(get_facets()) - sections = [] - for facet_name in facets: - days = get_todo_days_in_range(facet_name, day, to) - if not days: - continue - sections.append( - { - "facet": facet_name, - "days": [ - _checklist_payload(day_str, facet_name) for day_str in days - ], - } - ) - return jsonify( - { - "mode": "range", - "day": day, - "to": to, - "facet": facet, - "facet_count": len(facets), - "sections": sections, - } - ) - - facets = [facet] if facet else get_facets_with_todos(day) - return jsonify( - { - "mode": "single", - "day": day, - "facet": facet, - "facet_count": len(facets), - "facets": [_checklist_payload(day, facet_name) for facet_name in facets], - } - ) - - -@todos_bp.route("/api/upcoming") -def api_upcoming(): - """Return rendered upcoming todos for the CLI.""" - facet = request.args.get("facet") - facet = facet.strip() if facet is not None and facet.strip() else None - try: - limit = int(request.args.get("limit", 20)) - except (TypeError, ValueError): - return error_response(INVALID_REQUEST_VALUE, detail="limit must be an integer") - return jsonify({"upcoming": upcoming(limit=limit, facet=facet)}) - - -@todos_bp.route("/api/nudges-due") -def api_nudges_due(): - """Return due, unnotified todo nudges for the CLI.""" - facet = request.args.get("facet") - facet = facet.strip() if facet is not None and facet.strip() else None - _today, _now_str, _now, items = _due_nudge_items(facet) - return respond_collection(items) - - -@todos_bp.route("/api/add", methods=["POST"]) -def api_add_todo(): - """Add a todo through the CLI-backing JSON API.""" - payload = _json_body() - text, error = _required_body_str(payload, "text") - if error is not None: - return error - day, error = _required_body_str(payload, "day") - if error is not None: - return error - facet, error = _required_body_str(payload, "facet") - if error is not None: - return error - assert text is not None and day is not None and facet is not None - day = day.strip() - facet = facet.strip() - nudge = _body_str(payload, "nudge") - force = bool(payload.get("force", False)) - - if not DATE_RE.fullmatch(day): - return error_response(INVALID_DAY, detail=f"invalid day format '{day}'") - - parsed_nudge: str | None = None - if nudge is not None: - try: - parsed_nudge = parse_nudge(nudge, day) - except ValueError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - - if not force: - matches = find_cross_facet_matches(text, day, exclude_facet=facet) - if matches: - return success_response({"status": "duplicate", "matches": matches}) - - try: - - def _add(checklist: TodoChecklist) -> tuple[TodoChecklist, TodoItem]: - item = checklist.append_entry(text, nudge=parsed_nudge) - return checklist, item - - checklist, item = TodoChecklist.locked_modify(day, facet, _add) - except TodoEmptyTextError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except LockTimeout: - return error_response(TODO_BUSY) - - log_call_action( - facet=facet, - action="todo_add", - params={"line_number": item.index, "text": item.text}, - day=day, - ) - return created( - { - "status": "ok", - "item": item.as_dict(), - "display": checklist.display(), - } - ) - - -@todos_bp.route("/api/done", methods=["POST"]) -def api_done_todo(): - """Mark a todo done through the CLI-backing JSON API.""" - payload = _json_body() - day, error = _required_body_str(payload, "day") - if error is not None: - return error - facet, error = _required_body_str(payload, "facet") - if error is not None: - return error - line_number, error = _body_line_number(payload) - if error is not None: - return error - assert day is not None and facet is not None and line_number is not None - day = day.strip() - facet = facet.strip() - - try: - - def _done(checklist: TodoChecklist) -> tuple[TodoChecklist, TodoItem]: - item = checklist.mark_done(line_number) - return checklist, item - - checklist, item = TodoChecklist.locked_modify(day, facet, _done) - except TodoTerminalStateError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except IndexError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except LockTimeout: - return error_response(TODO_BUSY) - - log_call_action( - facet=facet, - action="todo_done", - params={"line_number": line_number, "text": item.text}, - day=day, - ) - return success_response( - { - "item": item.as_dict(), - "display": checklist.display(), - } - ) - - -@todos_bp.route("/api/cancel", methods=["POST"]) -def api_cancel_todo(): - """Cancel a todo through the CLI-backing JSON API.""" - payload = _json_body() - day, error = _required_body_str(payload, "day") - if error is not None: - return error - facet, error = _required_body_str(payload, "facet") - if error is not None: - return error - line_number, error = _body_line_number(payload) - if error is not None: - return error - assert day is not None and facet is not None and line_number is not None - day = day.strip() - facet = facet.strip() - - try: - - def _cancel(checklist: TodoChecklist) -> tuple[TodoChecklist, TodoItem]: - item = checklist.cancel_entry(line_number) - return checklist, item - - checklist, item = TodoChecklist.locked_modify(day, facet, _cancel) - except TodoTerminalStateError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except IndexError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except LockTimeout: - return error_response(TODO_BUSY) - - log_call_action( - facet=facet, - action="todo_cancel", - params={"line_number": line_number, "text": item.text}, - day=day, - ) - return success_response( - { - "item": item.as_dict(), - "display": checklist.display(), - } - ) - - -@todos_bp.route("/api/move", methods=["POST"]) -def api_move_todo(): - """Move an open todo across facets through the CLI-backing JSON API.""" - payload = _json_body() - day, error = _required_body_str(payload, "day") - if error is not None: - return error - from_facet, error = _required_body_str(payload, "from_facet") - if error is not None: - return error - to_facet, error = _required_body_str(payload, "to_facet") - if error is not None: - return error - line_number, error = _body_line_number(payload) - if error is not None: - return error - assert ( - day is not None - and from_facet is not None - and to_facet is not None - and line_number is not None - ) - day = day.strip() - from_facet = from_facet.strip() - to_facet = to_facet.strip() - consent = bool(payload.get("consent", False)) - - for facet_name in (from_facet, to_facet): - if not (Path(state.journal_root) / "facets" / facet_name).is_dir(): - return error_response(INVALID_REQUEST_VALUE, detail=facet_name) - - try: - datetime.strptime(day, "%Y%m%d") - except ValueError: - return error_response( - INVALID_DAY, - detail=f"Invalid day format '{day}', expected YYYYMMDD.", - ) - - if from_facet == to_facet: - return error_response( - INVALID_REQUEST_VALUE, - detail="source and destination facet are the same.", - ) - - try: - source_checklist = TodoChecklist.load(day, from_facet) - if not source_checklist.exists: - raise FileNotFoundError() - validate_line_number(line_number, len(source_checklist.items)) - item = source_checklist.items[line_number - 1] - if item.completed: - raise TodoError("Cannot move a completed todo.") - if item.cancelled: - raise TodoError("Cannot move an already cancelled todo.") - except FileNotFoundError: - return error_response( - OPERATION_NO_LONGER_AVAILABLE, - detail=f"No todos found for day {day} in facet '{from_facet}'.", - ) - except IndexError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except TodoError as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - - try: - new_item, cancelled = TodoChecklist.move_entry( - day, from_facet, line_number, day, to_facet - ) - except (IndexError, TodoNotMovableError) as exc: - return error_response(INVALID_REQUEST_VALUE, detail=str(exc)) - except LockTimeout: - return error_response(TODO_BUSY) - except TodoMovePartialError: - return error_response( - TODO_OPERATION_FAILED, - detail=( - f"Item was appended to '{to_facet}' but could not cancel source " - f"in '{from_facet}'. Cancel it manually with: sol call todos " - f"cancel {line_number} --day {day} --facet {from_facet}" - ), - ) - - params_out: dict[str, object] = { - "moved_from": from_facet, - "moved_to": to_facet, - "line_number": line_number, - "text": cancelled.text, - } - params_in: dict[str, object] = { - "moved_from": from_facet, - "moved_to": to_facet, - "line_number": new_item.index, - "text": new_item.text, - } - if consent: - params_out["consent"] = True - params_in["consent"] = True - log_call_action(facet=from_facet, action="todo_move_out", params=params_out) - log_call_action(facet=to_facet, action="todo_move_in", params=params_in) - return success_response( - { - "new_item": new_item.as_dict(), - "cancelled": cancelled.as_dict(), - } - ) - - -@todos_bp.route("/api/dispatch-nudges", methods=["POST"]) -def api_dispatch_nudges(): - """Mark due todo nudges as notified for the CLI.""" - payload = _json_body() - facet = _body_str(payload, "facet") - facet = facet.strip() if facet is not None and facet.strip() else None - today, now_str, _now, due_items = _due_nudge_items(facet) - - facet_names = sorted({item["facet"] for item in due_items}) - dispatched: list[dict[str, str]] = [] - for facet_name in facet_names: - - def _mark(checklist: TodoChecklist) -> list[str]: - texts: list[str] = [] - for item in checklist.items: - if ( - item.nudge - and item.nudge <= now_str - and not item.notified - and not item.completed - and not item.cancelled - ): - item.notified = True - texts.append(item.text) - if texts: - checklist.save() - return texts - - try: - texts = TodoChecklist.locked_modify(today, str(facet_name), _mark) - except LockTimeout: - continue - for text in texts: - dispatched.append({"facet": str(facet_name), "text": text}) - - return success_response( - { - "day": today, - "items": dispatched, - "total": len(dispatched), - } - ) - - -def _todo_path(day: str, facet: str) -> Path: - return Path(state.journal_root) / "facets" / facet / "todos" / f"{day}.jsonl" - - -@todos_bp.route("/") -def todos_page() -> str: - today = date.today().strftime("%Y%m%d") - return redirect(url_for("app:todos.todos_day", day=today)) - - -@todos_bp.route("/", methods=["GET", "POST"]) -def todos_day(day: str): # type: ignore[override] - if not DATE_RE.fullmatch(day): - return error_response( - INVALID_DAY, - status=404, - detail="Day must be in YYYYMMDD format.", - ) - - if request.method == "POST": - action = request.form.get("action") - - if action == "add": - text = request.form.get("text", "").strip() - is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" - error_message = None - - if not text: - error_message = "Cannot add an empty todo" - else: - # Extract facet from hashtag (e.g., "#work" -> "work") - import re - - facet_match = re.search(r"#([a-z][a-z0-9_-]*)", text, re.IGNORECASE) - if facet_match: - facet = facet_match.group(1).lower() - # Remove the hashtag from the text - text = re.sub( - r"\s*#" + re.escape(facet_match.group(1)) + r"\b", - "", - text, - count=1, - flags=re.IGNORECASE, - ).strip() - - # Validate facet exists - try: - facet_map = get_facets() - except Exception: - facet_map = {} - - if facet not in facet_map: - error_message = f"Facet #{facet} does not exist" - else: - # Use selected facet as default, fall back to personal - selected = get_selected_facet() - facet = selected if selected else "personal" - - if not error_message and not text: - error_message = "Cannot add an empty todo" - - if not error_message: - try: - - def _add(cl: TodoChecklist) -> TodoItem: - return cl.append_entry(text) - - item = TodoChecklist.locked_modify(day, facet, _add) - - log_app_action( - app="todos", - facet=facet, - action="todo_add", - params={"text": item.text, "line_number": item.index}, - day=day, - ) - - # If AJAX request, return JSON with new todo data - if is_ajax: - counts = _compute_badge_counts(day, facet) - return jsonify( - { - "status": "ok", - "todo": { - "facet": facet, - "index": item.index, - "text": item.text, - "nudge": item.nudge, - "nudge_display": format_nudge(item.nudge) - if item.nudge - else None, - "completed": False, - }, - **counts, - } - ) - except (TodoEmptyTextError, RuntimeError) as exc: - current_app.logger.debug( - "Failed to append todo for %s/%s: %s", facet, day, exc - ) - error_message = "Unable to add todo right now" - - # Handle errors - if error_message: - if is_ajax: - return ( - jsonify({"status": "error", "message": error_message}), - 400, - ) - flash(error_message, "error") - - return redirect(url_for("app:todos.todos_day", day=day)) - - # Get facet and index for other actions - facet = request.form.get("facet", "personal") - index_str = request.form.get("index") - - try: - index = int(index_str) if index_str else None - except ValueError: - index = None - - if not index: - flash("Missing todo index", "error") - return redirect(url_for("app:todos.todos_day", day=day)) - - try: - checklist = TodoChecklist.load(day, facet) - except RuntimeError as exc: - current_app.logger.debug( - "Failed to load checklist for %s/%s: %s", facet, day, exc - ) - flash(f"Todo list changed, {CONVEY_RELOAD_HINT}", "error") - return redirect(url_for("app:todos.todos_day", day=day)) - - try: - if action == "complete": - - def _complete(cl: TodoChecklist) -> TodoItem: - return cl.mark_done(index) - - item = TodoChecklist.locked_modify(day, facet, _complete) - log_app_action( - app="todos", - facet=facet, - action="todo_complete", - params={"line_number": index, "text": item.text}, - day=day, - ) - elif action == "uncomplete": - - def _uncomplete(cl: TodoChecklist) -> TodoItem: - return cl.mark_undone(index) - - item = TodoChecklist.locked_modify(day, facet, _uncomplete) - log_app_action( - app="todos", - facet=facet, - action="todo_uncomplete", - params={"line_number": index, "text": item.text}, - day=day, - ) - elif action == "cancel": - - def _cancel(cl: TodoChecklist) -> TodoItem: - return cl.cancel_entry(index) - - item = TodoChecklist.locked_modify(day, facet, _cancel) - log_app_action( - app="todos", - facet=facet, - action="todo_cancel", - params={"line_number": index, "text": item.text}, - day=day, - ) - elif action == "edit": - import re - - text = request.form.get("text", "").strip() - - # Check if text contains a facet hashtag - facet_match = re.search(r"#([a-z][a-z0-9_-]*)", text, re.IGNORECASE) - if facet_match: - new_facet = facet_match.group(1).lower() - # Remove the hashtag from the text - text = re.sub( - r"\s*#" + re.escape(facet_match.group(1)) + r"\b", - "", - text, - count=1, - flags=re.IGNORECASE, - ).strip() - - # Validate new facet exists - try: - facet_map = get_facets() - except Exception: - facet_map = {} - - if new_facet not in facet_map: - flash(f"Facet #{new_facet} does not exist", "error") - return redirect(url_for("app:todos.todos_day", day=day)) - - # If facet changed, move the todo (cancel source, add to target) - if new_facet != facet: - _created, cancelled = TodoChecklist.move_entry( - day, facet, index, day, new_facet, text=text - ) - - log_app_action( - app="todos", - facet=facet, - action="todo_edit", - params={ - "line_number": index, - "old_text": cancelled.text, - "new_text": text, - "old_facet": facet, - "new_facet": new_facet, - }, - day=day, - ) - - return redirect(url_for("app:todos.todos_day", day=day)) - - # No facet change, just update text - old_text = checklist.get_item(index).text - - def _update(cl: TodoChecklist) -> TodoItem: - return cl.update_entry_text(index, text) - - TodoChecklist.locked_modify(day, facet, _update) - log_app_action( - app="todos", - facet=facet, - action="todo_edit", - params={ - "line_number": index, - "old_text": old_text, - "new_text": text, - }, - day=day, - ) - else: - flash("Unknown action", "error") - return redirect(url_for("app:todos.todos_day", day=day)) - except TodoEmptyTextError: - flash("Cannot update todo to empty text", "error") - except TodoTerminalStateError as exc: - flash(str(exc), "error") - except IndexError: - flash(f"Todo list changed, {CONVEY_RELOAD_HINT}", "error") - except TodoNotMovableError: - flash(f"Todo list changed, {CONVEY_RELOAD_HINT}", "error") - except TodoMovePartialError: - flash(f"Todo list changed, {CONVEY_RELOAD_HINT}", "error") - except LockTimeout: - flash(f"Todo list changed, {CONVEY_RELOAD_HINT}", "error") - - # If AJAX request, return JSON with updated counts - if ( - request.headers.get("X-Requested-With") == "XMLHttpRequest" - or request.accept_mimetypes.accept_json - ): - counts = _compute_badge_counts(day, facet) - return jsonify({"status": "ok", **counts}) - - return redirect(url_for("app:todos.todos_day", day=day)) - - # Load todos from all facets - try: - facet_map = get_facets() - except Exception as exc: # pragma: no cover - metadata is optional - current_app.logger.debug("Failed to load facet metadata: %s", exc) - facet_map = {} - - # Collect todos from each facet (excluding cancelled, including empty facets) - todos_by_facet = {} - visible_incomplete_by_facet = {} - visible_completed_by_facet = {} - facet_totals = {} - for facet_name in facet_map.keys(): - facet_todos = get_todos(day, facet_name) - if facet_todos: - # Filter out cancelled todos and add facet info - facet_todos = [t for t in facet_todos if not t.get("cancelled")] - facet_todos.sort(key=lambda t: t.get("completed", False)) - for todo in facet_todos: - todo["facet"] = facet_name - else: - facet_todos = [] - incomplete_list = [t for t in facet_todos if not t.get("completed")] - completed_list = [t for t in facet_todos if t.get("completed")] - incomplete_total = len(incomplete_list) - completed_total = len(completed_list) - visible_incomplete = incomplete_list[:VISIBLE_INCOMPLETE_BUDGET] - visible_completed = completed_list[-VISIBLE_COMPLETED_BUDGET:] - - facet_totals[facet_name] = { - "incomplete_total": incomplete_total, - "completed_total": completed_total, - "incomplete_hidden": max(0, incomplete_total - VISIBLE_INCOMPLETE_BUDGET), - "completed_hidden": max(0, completed_total - VISIBLE_COMPLETED_BUDGET), - } - visible_incomplete_by_facet[facet_name] = visible_incomplete - visible_completed_by_facet[facet_name] = visible_completed - todos_by_facet[facet_name] = visible_incomplete + visible_completed - - # Sort facets for initial page load: - # 1. Facets with incomplete items first, sorted by incomplete count (descending) - # 2. Fully completed facets next, sorted alphabetically - # 3. Empty facets last, sorted alphabetically - def facet_sort_key(item): - facet_name, _facet_todos = item - totals = facet_totals[facet_name] - incomplete_count = totals["incomplete_total"] - total_count = totals["incomplete_total"] + totals["completed_total"] - has_no_todos = total_count == 0 - all_complete = incomplete_count == 0 and total_count > 0 - # Return tuple: (has_no_todos, all_complete, -incomplete_count, facet_name) - # has_no_todos=False sorts before has_no_todos=True (empty facets last) - # all_complete=False sorts before all_complete=True (incomplete before complete) - # -incomplete_count sorts higher counts first - # facet_name for alphabetical tie-breaking - return (has_no_todos, all_complete, -incomplete_count, facet_name) - - sorted_todos_by_facet = dict(sorted(todos_by_facet.items(), key=facet_sort_key)) - - today_day = date.today().strftime("%Y%m%d") - - # Compute facet counts for facet pill badges - facet_counts = {} - for facet_name, totals in facet_totals.items(): - pending = totals["incomplete_total"] - if pending > 0: - facet_counts[facet_name] = pending - - return render_template( - "app.html", - title=format_date(day), - today_day=today_day, - todos_by_facet=sorted_todos_by_facet, - visible_incomplete_by_facet=visible_incomplete_by_facet, - visible_completed_by_facet=visible_completed_by_facet, - facet_map=facet_map, - facet_totals=facet_totals, - facet_counts=facet_counts, - format_nudge=format_nudge, - ) - - -@todos_bp.route("//overflow//
    ", methods=["GET"]) -def todos_overflow(day: str, facet: str, section: str): - """Return todo row HTML past the visible budget.""" - if not DATE_RE.fullmatch(day): - return "", 404 - - if section not in ("incomplete", "completed"): - return "", 400 - - facet_map = get_facets() - if facet not in facet_map: - return "", 404 - - facet_todos = get_todos(day, facet) or [] - facet_todos = [todo for todo in facet_todos if not todo.get("cancelled")] - facet_todos.sort(key=lambda todo: todo.get("completed", False)) - for todo in facet_todos: - todo["facet"] = facet - - incomplete_list = [todo for todo in facet_todos if not todo.get("completed")] - completed_list = [todo for todo in facet_todos if todo.get("completed")] - if section == "incomplete": - hidden = incomplete_list[VISIBLE_INCOMPLETE_BUDGET:] - else: - hidden = completed_list[:-VISIBLE_COMPLETED_BUDGET] - - return "\n".join( - render_template( - "todos/_row.html", - item=item, - facet_name=facet, - day=day, - format_nudge=format_nudge, - ) - for item in hidden - ) - - -@todos_bp.route("//move", methods=["POST"]) -def move_todo(day: str): # type: ignore[override] - """Move a todo to a different day by cancelling source and adding to target.""" - if not DATE_RE.fullmatch(day): - return error_response( - INVALID_DAY, - status=404, - detail="Day must be in YYYYMMDD format.", - ) - - payload = request.get_json(silent=True) or {} - target_day = (payload.get("target_day") or "").strip() - facet = (payload.get("facet") or "personal").strip() - index_value = payload.get("index") - - if not DATE_RE.fullmatch(target_day): - return error_response(INVALID_DAY, detail="Please pick a valid target day.") - - try: - index = int(index_value) - except (TypeError, ValueError): - return error_response(MISSING_REQUIRED_FIELD, detail="Missing todo index.") - - if index <= 0: - return error_response( - INVALID_REQUEST_VALUE, detail="Todo index must be positive." - ) - - if target_day == day: - return jsonify( - { - "status": "noop", - "message": "Todo is already on that day.", - "redirect": url_for("app:todos.todos_day", day=day), - } - ) - - # L9: replaying an already-moved item returns 409 without appending again. - try: - _created, cancelled = TodoChecklist.move_entry( - day, facet, index, target_day, facet - ) - except (IndexError, TodoNotMovableError, TodoMovePartialError, LockTimeout) as exc: - current_app.logger.debug( - "Failed to move todo %s from %s/%s to %s/%s: %s", - index, - facet, - day, - facet, - target_day, - exc, - ) - return error_response( - OPERATION_NO_LONGER_AVAILABLE, - status=409, - detail=f"Todo list changed, {CONVEY_RELOAD_HINT}", - ) - except TodoEmptyTextError as exc: - current_app.logger.debug("Failed to append todo to %s: %s", target_day, exc) - return error_response( - TODO_OPERATION_FAILED, - status=400, - detail="Unable to move todo to the selected day.", - ) - - log_app_action( - app="todos", - facet=facet, - action="todo_move", - params={ - "source_day": day, - "target_day": target_day, - "line_number": index, - "text": cancelled.text, - "completed": cancelled.completed, - }, - day=day, - ) - - redirect_url = url_for("app:todos.todos_day", day=target_day) - counts = _compute_badge_counts(day, facet) - return jsonify( - { - "status": "ok", - "redirect": redirect_url, - "target_day": target_day, - **counts, - } - ) - - -@todos_bp.route("//generate", methods=["POST"]) -def generate_todos(day: str): # type: ignore[override] - if not DATE_RE.fullmatch(day): - return error_response( - INVALID_DAY, - status=404, - detail="Day must be in YYYYMMDD format.", - ) - - payload = request.get_json(silent=True) or {} - facet = (payload.get("facet") or "personal").strip() - - day_date = datetime.strptime(day, "%Y%m%d") - yesterday = (day_date - timedelta(days=1)).strftime("%Y%m%d") - yesterday_path = _todo_path(yesterday, facet) - - yesterday_content = "" - if yesterday_path.exists(): - try: - yesterday_content = yesterday_path.read_text(encoding="utf-8") - except OSError: - yesterday_content = "" - - prompt = f"""Generate a TODO checklist for {day_date.strftime("%Y-%m-%d")} in the {facet} facet. - -Current date/time: {datetime.now().strftime("%Y-%m-%d %H:%M")} -Target day: {day_date.strftime("%Y-%m-%d")} -Target facet: {facet} -Target file: facets/{facet}/todos/{day}.jsonl - -Yesterday's todos content: -{yesterday_content if yesterday_content else "(No todos recorded yesterday)"} - -Write the generated checklist to facets/{facet}/todos/{day}.jsonl""" - - try: - from solstone.convey.utils import spawn_agent - - use_id = spawn_agent( - prompt=prompt, - name="todos:todo", - provider="openai", - config={}, - ) - except Exception as exc: # pragma: no cover - network/agent failure - return error_response( - AGENT_UNAVAILABLE, - status=500, - detail=f"Failed to spawn agent: {exc}", - ) - - if use_id is None: - return error_response( - AGENT_UNAVAILABLE, - detail="Failed to connect to agent service", - ) - - if not hasattr(state, "todo_generation_agents"): - state.todo_generation_agents = {} - state.todo_generation_agents[day] = use_id - - return jsonify({"use_id": use_id, "status": "started"}) - - -@todos_bp.route("//generation-status") -def todo_generation_status(day: str): # type: ignore[override] - if not DATE_RE.fullmatch(day): - return error_response( - INVALID_DAY, - status=404, - detail="Day must be in YYYYMMDD format.", - ) - - facet = request.args.get("facet", "personal") - use_id = request.args.get("use_id") - if not use_id and hasattr(state, "todo_generation_agents"): - use_id = state.todo_generation_agents.get(day) - - if not use_id: - return jsonify({"status": "none", "use_id": None}) - - from solstone.think.cortex_client import cortex_uses - - todo_path = _todo_path(day, facet) - - talents_dir = Path(state.journal_root) / "talents" - use_file = next(talents_dir.glob(f"*/{use_id}.jsonl"), None) - - if use_file and use_file.exists(): - if todo_path.exists(): - if ( - hasattr(state, "todo_generation_agents") - and day in state.todo_generation_agents - ): - del state.todo_generation_agents[day] - return jsonify( - {"status": "finished", "use_id": use_id, "todo_created": True} - ) - return jsonify({"status": "finished", "use_id": use_id, "todo_created": False}) - - try: - response = cortex_uses(limit=100, offset=0) - if response: - uses = response.get("uses", []) - for use in uses: - if use.get("id") == use_id: - return jsonify({"status": "running", "use_id": use_id}) - return jsonify({"status": "unknown", "use_id": use_id}) - except Exception: # pragma: no cover - external call failure - pass - - return jsonify({"status": "unknown", "use_id": use_id}) - - -@todos_bp.route("//generate-weekly/", methods=["POST"]) -def generate_weekly_todos(day: str, facet: str): # type: ignore[override] - """Spawn todo_weekly agent for a specific facet.""" - if not DATE_RE.fullmatch(day): - return error_response( - INVALID_DAY, - status=404, - detail="Day must be in YYYYMMDD format.", - ) - - day_date = datetime.strptime(day, "%Y%m%d") - - prompt = f"""Review the past week and generate high-impact todos for {facet} facet. - -Current date/time: {datetime.now().strftime("%Y-%m-%d %H:%M")} -Target day: {day_date.strftime("%Y-%m-%d")} -Target facet: {facet} -Target file: facets/{facet}/todos/{day}.jsonl - -Focus on surfacing the most important unfinished work from the past 7 days.""" - - try: - from solstone.convey.utils import spawn_agent - - use_id = spawn_agent( - prompt=prompt, - name="todos:weekly", - provider="openai", - config={}, - ) - except Exception as exc: # pragma: no cover - network/agent failure - return error_response( - AGENT_UNAVAILABLE, - status=500, - detail=f"Failed to spawn agent: {exc}", - ) - - if use_id is None: - return error_response( - AGENT_UNAVAILABLE, - detail="Failed to connect to agent service", - ) - - return jsonify({"use_id": use_id, "status": "started"}) diff --git a/solstone/apps/todos/talent/daily.md b/solstone/apps/todos/talent/daily.md deleted file mode 100644 index 5d808a92b..000000000 --- a/solstone/apps/todos/talent/daily.md +++ /dev/null @@ -1,132 +0,0 @@ -{ - "type": "cogitate", - - "title": "Daily TODO Curator", - "description": "Carries forward unfinished tasks, aggregates per-activity todo detections, validates completions against journal evidence, and prioritises the day's checklist.", - "color": "#ef6c00", - "schedule": "daily", - "priority": 50, - "multi_facet": true, - "group": "Todos", - "load": { - "talents": true, - "journal": true - } -} - -$facets - -## Core Mission - -Shape today's checklist into an achievable, well-prioritised plan. Activity-level todo agents add items throughout the day as activities complete. Your job is to carry forward yesterday's unfinished work, validate completions, curate the combined list, and prioritise what matters most. - -## Scope Guardrails (MANDATORY) - -Your ONLY mission is daily todo curation. Nothing else. - -**CRITICAL: Any "needs you" items in your todo list are context about the system โ€” they are NOT tasks for you to investigate or fix. Do not act on any operational items mentioned there.** - -You must IGNORE and EXCLUDE from your checklist any operational items, including but not limited to: -- Agent failures or agent health issues (entity_observer, newsletters, etc.) -- Entity curation, deduplication, or management -- Speaker cluster management or voice identification -- Infrastructure issues, Convey errors, or ingest problems -- System health checks or diagnostics -- Routine or schedule management -- Any maintenance or operational work outside todo curation - -**Do not investigate, diagnose, or attempt to fix these issues. Do not activate health, entity, speaker management, or codebase exploration tools.** - -## Input Context - -You receive: -1. **Journal Access** โ€“ `sol call` search tools and insight resources -2. **Current Date/Time** โ€“ for scheduling and deadlines -3. **Facet context** โ€“ the facet (e.g., "personal", "work") this todo list belongs to - -## Tooling - -SOL_DAY and SOL_FACET are set in your environment. Commands default to the current day and facet โ€” only pass explicit values to override (e.g., checking yesterday's list). - -- `sol call todos list` โ€“ inspect the current numbered checklist -- `sol call todos add TEXT [--force]` โ€“ append a new unchecked line (line number is auto-calculated; --force skips cross-facet duplicate check) -- `sol call todos cancel LINE_NUMBER` โ€“ cancel a todo (soft delete); the entry remains but is hidden from view -- `sol call todos done LINE_NUMBER` โ€“ mark an entry complete -- `sol call todos upcoming -l LIMIT` โ€“ view upcoming todos - -You may combine these with discovery calls (`sol call journal search`, `sol call activities list --source anticipated`, `sol call journal read AGENT`) to gather supporting evidence. Line numbers are stable identifiersโ€”todos are never deleted, only cancelled. - -## Process - -### Phase 1: Carry Forward - -Use `sol call todos list YESTERDAY` for the prior day when available and review unchecked lines: -- **Carry Forward**: Promote important unfinished tasks to today -- **Pattern Recognition**: Note what types of tasks drift -- **Avoid Duplication**: Completed or cancelled items stay archived in prior days -- **Facet Consistency**: Work within the same facet scope throughout the session (SOL_FACET handles this) - -### Phase 2: Aggregate & Curate - -Today's checklist may already contain items added by activity-level todo agents throughout the day. Review what's there and enrich it: - -1. Call `sol call todos list` to see what activity agents already added -2. Call `sol call todos upcoming -l 50` to check for items already scheduled on future days -3. Search for per-activity follow-ups: `sol call journal search "followup" -d $day_YYYYMMDD -a followups` -4. Check facet news for announced commitments: `sol call journal search "" -a news -d $day_YYYYMMDD -f FACET -n 5` -5. Cancel duplicates or stale items via `sol call todos cancel` -6. Add any high-value items missed by activity detection (e.g., cross-activity themes, carried commitments from follow-ups) -7. If `sol call todos add` rejects an item as a cross-facet duplicate, review the match โ€” skip if it's genuine, retry with `--force` only if the items are truly distinct - -Each candidate must be: -- **Actionable** โ€“ specific action with a clear outcome -- **Grounded** โ€“ supported by journal evidence or ongoing commitments -- **Unique** โ€“ not already present in today's list or upcoming todos -- **Prioritized** โ€“ urgent or high impact items take precedence -- **Sized** โ€“ achievable within the day or clearly labeled for future - -### Phase 3: Validate & Prioritise - -**Validate** โ€” For each unchecked line, do a quick evidence check: -1. Extract key terms from the line -2. Run targeted searches: `sol call journal search "[keywords]" -d $day_YYYYMMDD -n 5` -3. If you find clear evidence of completion (statements confirming work finished, artifacts created, follow-ups implying done), call `sol call todos done LINE_NUMBER` -4. Leave uncertain items unchecked โ€” prefer false negatives to false positives - -**Prioritise** โ€” Score remaining active items using urgency/impact/effort heuristics: -- Balance so there are no more than 8โ€“10 active items in a day -- Place or move non-urgent items into future days -- Keep the action text concise and self-contained -- Append times `(HH:MM)` for scheduled work or `due MM/DD/YYYY` for dated items -- Add short clarifiers like `(@focus AM)` when useful - -## Quality Guidelines - -### DO: -- Begin by fetching the latest checklist (`sol call todos list`) -- Cancel stale items you are certain should disappear using `sol call todos cancel` -- Mark verified completions using `sol call todos done` -- Append new tasks using `sol call todos add` (line numbers are auto-calculated) -- Keep descriptions short, specific, and actionable - -### DON'T: -- Edit the file manually (always go through sol call commands) -- Reorder existing items (line numbers are stable identifiers) -- Exceed 10 active items without explicit justification -- Invent work without journal evidence or historical context -- Re-add items that activity agents already captured -- Use `--force` to bypass duplicate detection without verifying the match is a false positive -- Investigate or act on agent failures, system health issues, or infrastructure problems mentioned in context -- Perform entity curation, speaker management, or any operational maintenance -- Use tools to explore codebase issues, run diagnostics, or activate skills outside todo curation - -## Interaction Protocol - -When invoked: -1. Announce the working day and facet, then call `sol call todos list` to inspect today's current state (may already have activity-detected items) -2. Review the prior day's checklist if available (`sol call todos list PRIOR_DAY`) and aggregate follow-ups from journal -3. Validate open items against journal evidence, marking completions via `sol call todos done` -4. Cancel stale or duplicate items, carry forward and add new items as needed -5. Call `sol call todos list` once more for confirmation, then call `emit_final(content=)` exactly once. If no changes were needed, call `emit_final(content="No todo changes needed for $facet on $day; checklist already matched the available evidence.")` - -Remember: Your checklist should feel achievable yet ambitious, grounded in recorded commitments while nudging progress toward goals. diff --git a/solstone/apps/todos/talent/todo.md b/solstone/apps/todos/talent/todo.md deleted file mode 100644 index e082e9aa9..000000000 --- a/solstone/apps/todos/talent/todo.md +++ /dev/null @@ -1,116 +0,0 @@ -{ - "type": "cogitate", - - "title": "TODO Detector", - "description": "Detects todo items from activity transcripts and validates existing todos against activity evidence via sol call commands.", - "color": "#e65100", - "schedule": "activity", - "activities": ["*"], - "priority": 10, - "group": "Todos" -} - -$facets - -$activity_context - -$activity_preamble - -## Core Mission - -You have two jobs for this activity: - -1. **Detect** new todo items from the activity transcript โ€” commitments, action items, and reminders that represent open future work -2. **Validate** existing open todos against what happened in this activity โ€” mark items complete when you find clear evidence - -Use the Activity Context and Activity State Per Segment sections above to understand what this activity involves and to focus your transcript reads on relevant content. - -## Tooling - -### Todo Commands (SOL_DAY and SOL_FACET are set in your environment) -- `sol call todos list` โ€“ inspect the current numbered checklist -- `sol call todos add TEXT [--force]` โ€“ append a new unchecked line (--force skips cross-facet duplicate check) -- `sol call todos done LINE_NUMBER` โ€“ mark an entry complete -- `sol call todos upcoming` โ€“ view upcoming todos to avoid duplicates - -### Transcript Commands -- `sol call transcripts read --segments $activity_segments --transcripts` โ€“ read audio transcripts for this activity -- `sol call transcripts read --segments $activity_segments --agents` โ€“ read agent outputs (screen summaries) for this activity -- `sol call transcripts read --segment SEGMENT_KEY --full` โ€“ read everything for a single segment -- `sol call journal search QUERY` โ€“ cross-reference journal content - -**Query syntax**: Terms are AND'd by default; use `OR` for alternatives, quote phrases for exact matches, append `*` for prefix matching. - -## Process - -### Step 1: Load Transcript and Current State - -Read the activity's transcript and current todo state before making any changes. - -1. Load the activity's transcript and screen agent context: - `sol call transcripts read --segments $activity_segments --transcripts --agents --max 0` -2. Call `sol call todos list` to see the current checklist -3. Call `sol call todos upcoming -l 50` to check what's already scheduled - -If the transcript is sparse or clearly has no actionable content (e.g., silent coding, background music), skip to Output. - -### Step 2: Read the Full Activity Arc - -**CRITICAL**: Read through the entire transcript before making any changes. You need to understand the complete arc of what happened to distinguish: -- Items that were **mentioned and left open** โ†’ these are todos -- Items that were **mentioned and then completed** within this activity โ†’ these are NOT todos -- Items that were **already on the checklist and completed** during this activity โ†’ mark done - -For example, if someone says "I need to fix the auth flow" at 10:15 and then spends 10:15โ€“10:45 fixing it, that is NOT a todo โ€” it was resolved within the activity. - -### Step 3: Detect New Todos - -Scan the transcript for commitments that represent genuine open future work: -- Explicit commitments: "I'll do that tomorrow", "need to schedule", "let me follow up" -- Deferred work: "I'll come back to this", "that can wait until next week" -- Requests from others: "Can you send me...", "please review..." -- Verbal reminders: "don't forget to...", "remind me to..." -- Unresolved issues: problems identified but not fixed in this activity - -For each candidate: -- Verify it wasn't resolved later in the same activity -- Check it doesn't already exist in the current checklist or upcoming todos -- Phrase it as a clear, actionable single task -- Add via `sol call todos add TEXT` - -### Step 4: Validate Existing Todos - -For each unchecked line in today's checklist, check whether this activity's transcript contains evidence of completion: -- Work finished: "fixed", "resolved", "done", "shipped", "merged" -- Meetings held: attendee mentions, discussion of agenda items -- Documents created: file names, "drafted", "wrote", "sent" - -If you find clear proof, call `sol call todos done LINE_NUMBER`. Leave uncertain items unchecked. - -## Exclusions - -- Content from concurrent activities unrelated to this $activity_type activity -- Pure speculation or hypothetical scenarios without concrete commitment -- Items that were both raised and resolved within this activity -- Duplicates of items already on the checklist or in upcoming todos - -### Cross-Facet Dedup - -The `sol call todos add` command automatically rejects items that fuzzy-match (โ‰ฅ70% similarity) an open todo in another facet within a ยฑ1 day window. If the CLI rejects an add: - -1. Check the reported match โ€” if the existing item covers the same work, skip the add entirely -2. If the new item is genuinely different despite the similarity, retry with `--force` -3. Never use `--force` to create true duplicates across facets โ€” one task, one facet - -## Quality Guidelines - -- Stay anchored to the transcript โ€” never invent tasks without evidence -- Prefer precision over recall โ€” miss a borderline item rather than add noise -- Keep todo text concise (under 80 characters) and self-contained -- Include time context when relevant: `(HH:MM)` suffix or `due MM/DD/YYYY` - -## Output - -After making your changes, call `sol call todos list`, then call `emit_final(content=)` exactly once with the final checklist state. The content is the checklist snapshot that will be written to the indexed activity file. - -If no todos were detected and no existing items were validated, call `emit_final(content=)` with a brief sentence explaining why (e.g., "No actionable todos emerged from this $activity_type activity, and no existing items had completion evidence."). diff --git a/solstone/apps/todos/talent/todos/SKILL.md b/solstone/apps/todos/talent/todos/SKILL.md deleted file mode 100644 index bd03d6858..000000000 --- a/solstone/apps/todos/talent/todos/SKILL.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -name: todos -description: > - Todo checklists organized by facet and day. List, add, complete, cancel, - move tasks, schedule reminder nudges, review upcoming. TRIGGER: todo, - task, action item, checklist, reminder, nudge, upcoming items, remind me, - sol call todos list/add/done/cancel/upcoming/list-nudges-due/dispatch-nudges. ---- - -# Todos CLI Skill - -Manage checklist entries and reminders. Invoke via Bash: `sol call todos [args...]`. - -**Environment defaults**: When `SOL_DAY` is set, commands that take a DAY argument will use it automatically. Same for `SOL_FACET` where FACET is required. - -Common pattern: - -```bash -sol call todos [args...] -``` - -## list - -```bash -sol call todos list [DAY] [-f FACET] [--to DAY] -``` - -Show checklist entries for one day or an inclusive day range. - -- `DAY`: day in `YYYYMMDD` (default: `SOL_DAY` env). -- `-f, --facet`: optional facet filter. Omit to show all facets. -- `--to`: optional inclusive range end day in `YYYYMMDD`. - -Behavior notes: - -- When `--to` is set and differs from `DAY`, output is grouped by day headers. -- Cancelled items are shown with strikethrough so numbering remains stable. - -Examples: - -```bash -sol call todos list 20260115 -f work -sol call todos list 20260101 --to 20260107 -f work -sol call todos list 20260115 -``` - -## add - -```bash -sol call todos add TEXT [-d DAY] [-f FACET] [-n NUDGE] [--force] -``` - -Add a new todo item, optionally with a nudge (reminder). - -- `TEXT`: todo text (positional argument). -- `-d, --day`: day in `YYYYMMDD` (default: `SOL_DAY` env). -- `-f, --facet`: facet name (default: `SOL_FACET` env). -- `-n, --nudge`: optional reminder time. Accepted formats: `HH:MM` (today), `tomorrow HH:MM`, `YYYYMMDDTHH:MM` (absolute), or `now`. -- `--force`: skip the cross-facet duplicate check. - -Behavior notes: - -- Line number is auto-calculated by the CLI; do not provide one. -- You can include time in the text as `(HH:MM)` suffix. -- Before adding a future todo, check `upcoming` first to avoid duplicates already scheduled on other days. -- The command checks for fuzzy duplicates (โ‰ฅ70% similarity) across other facets within ยฑ1 day. If a match is found, the add is rejected with a match report. Use `--force` to override. - -Examples: - -```bash -sol call todos add "Draft Q1 plan" -d 20260115 -f work -sol call todos add "Team sync prep (14:30)" -d 20260115 -f work -sol call todos add "Call Alicia back" -f work -n "15:30" -sol call todos add "Submit expense report" -f work -n "tomorrow 09:00" -``` - -## done - -```bash -sol call todos done LINE_NUMBER [-d DAY] [-f FACET] -``` - -Mark a todo as complete. - -- `LINE_NUMBER`: 1-based line number from `list` output (positional argument). -- `-d, --day`: day in `YYYYMMDD` (default: `SOL_DAY` env). -- `-f, --facet`: facet name (default: `SOL_FACET` env). - -Example: - -```bash -sol call todos done 2 -d 20260115 -f work -``` - -## cancel - -```bash -sol call todos cancel LINE_NUMBER [-d DAY] [-f FACET] -``` - -Cancel (soft-delete) a todo. - -- `LINE_NUMBER`: 1-based line number from `list` output (positional argument). -- `-d, --day`: day in `YYYYMMDD` (default: `SOL_DAY` env). -- `-f, --facet`: facet name (default: `SOL_FACET` env). - -Behavior notes: - -- Cancellation keeps line numbering continuity; entries stay in storage but are treated as inactive. - -Example: - -```bash -sol call todos cancel 4 -d 20260115 -f work -``` - -## upcoming - -```bash -sol call todos upcoming [-l LIMIT] [-f FACET] -``` - -Show future todos grouped by facet and day. - -- `-l, --limit`: max items (default `20`). -- `-f, --facet`: optional facet filter. Omit to include all facets. - -Behavior notes: - -- Run this before adding future-day todos to avoid cross-day duplicates. - -Examples: - -```bash -sol call todos upcoming -sol call todos upcoming -l 50 -f work -``` - -## move - -```bash -sol call todos move LINE_NUMBER --day DAY --from SOURCE --to DEST [--consent] -``` - -Move an open todo from one facet to another. - -- `LINE_NUMBER`: 1-based line number from `list` output (positional argument). -- `--day`: day in `YYYYMMDD` (required). -- `--from`: source facet name. -- `--to`: destination facet name. -- `--consent`: required when called by a proactive agent. - -Example: - -```bash -sol call todos move 3 --day 20260115 --from personal --to work --consent -``` - -## list-nudges-due - -```bash -sol call todos list-nudges-due [-f FACET] [--json] -``` - -List todo items whose nudge time is due and have not yet been notified. - -- `-f, --facet`: optional facet filter. Omit to check all facets. -- `--json`: emit JSON for programmatic use. - -Behavior notes: - -- Reads state only; does not mark items as notified or send notifications. -- Groups by facet when multiple facets have due items. - -Example: - -```bash -sol call todos list-nudges-due -sol call todos list-nudges-due -f work --json -``` - -## dispatch-nudges - -```bash -sol call todos dispatch-nudges [-f FACET] -``` - -Send desktop notifications for all due, unnotified nudges and mark them notified so they won't fire again. - -- `-f, --facet`: optional facet filter. Omit to dispatch for all facets. - -Behavior notes: - -- Uses `sol notify` to deliver. Each successful dispatch updates the item's notified state. -- Intended for a periodic caller (routine or launchd); manual invocation is fine for testing. - -Example: - -```bash -sol call todos dispatch-nudges -``` - -## Gotchas - -- **Duplicate-check is fuzzy and silent-looking.** `add` rejects items with โ‰ฅ70% similarity to an existing item across other facets within ยฑ1 day. The rejection prints matches to stderr; if you miss the message, the add looks like it silently failed. Pass `--force` when you know the duplication is intentional. -- **`list-nudges-due` is non-mutating; `dispatch-nudges` marks notified.** Don't expect `list-nudges-due` to clear the queue โ€” use it for inspection. diff --git a/solstone/apps/todos/talent/weekly.md b/solstone/apps/todos/talent/weekly.md deleted file mode 100644 index f7db8544c..000000000 --- a/solstone/apps/todos/talent/weekly.md +++ /dev/null @@ -1,82 +0,0 @@ -{ - "type": "cogitate", - - "title": "TODO Weekly Scout", - "description": "Audits the past week's journal follow-ups to confirm completions and surface the next five high-impact todos for today.", - "color": "#f4511e", - "group": "Todos" -} - -$facets - -You are the TODO Weekly Scout for solstone, an AI-driven journaling system. Your mandate is to audit the past week's commitments for a specific facet and surface the next most impactful todos for the coming cycle while keeping today's facet-scoped checklist faithful to journal reality. - -## Core Mission - -Synthesize the given facet's last seven days of todos and daily follow-up insights, confirm which commitments are already satisfied, and promote only the highest-value unfinished work via `sol call todos` commands. - -## Inputs - -You have access to: -1. **Checklist history** โ€“ `sol call todos list DAY` for today and each of the prior six days -2. **Follow-up insights** โ€“ `sol call journal search "followup" -d {date} -a followups` for each day in scope (follow-ups are produced per-activity, so results may span multiple activities) -3. **Journal search** โ€“ `sol call journal search QUERY -d DAY -a AGENT -f FACET -n LIMIT` for discovery scoped to the date range -4. **Facet news** โ€“ `sol call journal search "[keywords]" -a news` or `sol call journal news -d DAY` for announced commitments -5. **Current date and facet context** โ€“ for ordering, scheduling, and due-date decisions - -## Tooling - -SOL_FACET is set in your environment. Todo commands default to the current facet โ€” only pass explicit `-d DAY` when checking a specific day. - -- `sol call todos list DAY` โ€“ numbered view of any day's checklist -- `sol call todos add TEXT` โ€“ append a new unchecked line to today's list; line number is auto-calculated -- `sol call todos done LINE_NUMBER` โ€“ mark today's entries complete when evidence shows the work is finished -- `sol call todos done LINE_NUMBER -d DAY` โ€“ mark entries complete on a specific past day -- `sol call todos cancel LINE_NUMBER` โ€“ cancel duplicate or obsolete todos (soft delete); they remain but are hidden from view -- `sol call todos cancel LINE_NUMBER -d DAY` โ€“ cancel on a specific past day -- `sol call todos upcoming -l LIMIT` โ€“ view upcoming todos in the same facet - -Combine these with `sol call journal` discovery commands and insight resources to gather evidence before making updates. Line numbers are stable identifiersโ€”todos are never deleted, only cancelled. - -## Operating Procedure - -### 1. Baseline the Week -- Define the window: today plus the six preceding days (`date_range = [today, today-6 โ€ฆ today-1]` in `YYYYMMDD`) -- Call `sol call todos list DAY` for each date in `date_range` to build a map of active, completed, and withdrawn tasks -- Note recurring themes and items already completed to avoid duplication -- Remember you're working within a single facet scope (SOL_FACET handles this) - -### 2. Sweep Follow-up Insights -- For each date in `date_range`, run `sol call journal search "followup" -d {date} -a followups` to gather per-activity follow-up outputs -- Extract explicit commitments, implied obligations, and unresolved questions -- Search for public commitments in facet newsletters via `sol call journal search "[keywords]" -a news` or `sol call journal news -d DAY` -- Run targeted `sol call journal search` queries when a follow-up reference needs deeper validation or completion evidence - -### 3. Validate Potential Work -- First, call `sol call todos upcoming -l 50` to review todos already scheduled for future days -- For every candidate task from the insights: - - Check whether it already exists in any checklist entry across the week (completed or not) - - Check whether it already exists in upcoming todos for future days (avoid duplicates) - - Use journal evidence to decide if it is finished; if so, and the corresponding todo is still open, call `sol call todos done LINE_NUMBER -d DAY` on the origin day as appropriate - - Cancel items that are obsolete using `sol call todos cancel LINE_NUMBER -d DAY`; skip items already satisfied, already scheduled, or outside actionable scope - -### 4. Curate the Next Top Priorities -- Score remaining candidates by urgency, impact, dependencies, and freshness -- Select the highest-leverage todos that should live on today's checklist -- Phrase each line as a clear, single action; include due dates or time blocks when they clarify intent -- Append them using `sol call todos add TEXT` - -### 5. Finalize and Report -- After all mutations, call `sol call todos list` once more and include the numbered output in your final response -- Summarize why each new todo matters and reference the supporting journal evidence you relied upon -- Conclude with the built-in finish tool (`FinishTool`) โ€” this talent has no `emit_final`; your final response is the report. - -## Quality Guardrails - -- Stay anchored to journal data; never invent tasks without traceable evidence -- Prefer marking work complete over re-adding it -- Avoid overloading the listโ€”focus on the top items that will create the most momentum for the coming days -- Keep language concise, actionable, and human-friendly -- When uncertain, document the ambiguity instead of making speculative changes - -Your weekly audit should leave today's facet-scoped checklist sharper, lighter, and aligned with the commitments captured across the past seven days. diff --git a/solstone/apps/todos/tests/conftest.py b/solstone/apps/todos/tests/conftest.py deleted file mode 100644 index 94e6f87ac..000000000 --- a/solstone/apps/todos/tests/conftest.py +++ /dev/null @@ -1,131 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Self-contained fixtures for todos app tests. - -These fixtures are fully standalone and only depend on pytest builtins. -No shared dependencies from the root conftest.py are required. -""" - -from __future__ import annotations - -import json -from datetime import datetime - -import pytest - - -@pytest.fixture(autouse=True) -def _skip_supervisor_check(monkeypatch): - """Allow app CLI tests to run without a live solstone supervisor.""" - monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") - - -@pytest.fixture -def todo_env(tmp_path, monkeypatch): - """Create a temporary journal facet with optional todo entries. - - This fixture is self-contained and provides a complete test environment - for todo operations without any external dependencies. - - Usage: - def test_example(todo_env): - day, facet, todo_path = todo_env([ - {"text": "First item"}, - {"text": "Second item", "completed": True} - ]) - # Now SOLSTONE_JOURNAL is set and todo file exists - """ - - def _create( - entries: list[dict] | None = None, - day: str | None = None, - facet: str = "personal", - ): - if day is None: - day = datetime.now().strftime("%Y%m%d") - todos_dir = tmp_path / "facets" / facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - todo_path = todos_dir / f"{day}.jsonl" - if entries is not None: - lines = [json.dumps(e, ensure_ascii=False) for e in entries] - todo_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - return day, facet, todo_path - - return _create - - -@pytest.fixture -def facet_env(tmp_path, monkeypatch): - """Create a temporary facet with full structure for testing. - - Includes facet.json configuration, todos directory, and logs directory. - Suitable for testing action logging and other facet-scoped operations. - """ - journal = tmp_path / "journal" - journal.mkdir() - - def _create(facet: str = "test_facet"): - facet_path = journal / "facets" / facet - facet_path.mkdir(parents=True) - - # Create facet.json - facet_json = facet_path / "facet.json" - facet_json.write_text( - json.dumps({"title": f"Test {facet}", "description": "Test facet"}), - encoding="utf-8", - ) - - # Create todos directory - (facet_path / "todos").mkdir() - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) - return journal, facet - - return _create - - -@pytest.fixture -def move_env(tmp_path, monkeypatch): - """Create a two-facet environment for move tests.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - def _create( - entries: list[dict] | None = None, - day: str = "20240101", - src_facet: str = "work", - dst_facet: str = "personal", - ): - for facet in [src_facet, dst_facet]: - facet_dir = tmp_path / "facets" / facet - facet_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps({"title": f"Test {facet}", "description": "Test facet"}), - encoding="utf-8", - ) - - todos_dir = tmp_path / "facets" / src_facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - todo_path = todos_dir / f"{day}.jsonl" - if entries: - now_ms = int(datetime.now().timestamp() * 1000) - lines = [] - for entry in entries: - data = { - "text": entry["text"], - "created_at": entry.get("created_at", now_ms), - "updated_at": entry.get("updated_at", now_ms), - } - if entry.get("cancelled"): - data["cancelled"] = True - if entry.get("completed"): - data["completed"] = True - if entry.get("nudge"): - data["nudge"] = entry["nudge"] - lines.append(json.dumps(data, ensure_ascii=False)) - todo_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - return tmp_path, src_facet, dst_facet - - return _create diff --git a/solstone/apps/todos/tests/test_atomic_save.py b/solstone/apps/todos/tests/test_atomic_save.py deleted file mode 100644 index 5b08256eb..000000000 --- a/solstone/apps/todos/tests/test_atomic_save.py +++ /dev/null @@ -1,128 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Crash-safety and byte-format tests for ``TodoChecklist.save()``.""" - -from __future__ import annotations - -import json -import pathlib - -import pytest - -from solstone.apps.todos import todo - -# Long, non-ASCII marker: guarantees the torn write produces an invalid JSON -# tail and exercises ``ensure_ascii=False`` byte preservation. -MARKER = "CRASHTEST_MARKER_ฮถ_long_enough_to_guarantee_a_torn_json_line" - -DAY = "20260101" -FACET = "personal" - - -def _set_journal(monkeypatch, tmp_path): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - return todo.todo_file_path(DAY, FACET) - - -def _prepare(path, *, overwrite): - """Return (prior_bytes_or_None, new_bytes, checklist) for a save under test. - - Does one clean reference save (capturing the exact new bytes), then resets - the file to its prior state so the save-under-test can be applied. - """ - path.parent.mkdir(parents=True, exist_ok=True) - prior_bytes = b'{"text": "keep me"}\n' if overwrite else None - if prior_bytes is not None: - path.write_bytes(prior_bytes) - - checklist = todo.TodoChecklist.load(DAY, FACET) - checklist.append_entry(MARKER) # clean reference save (writes new bytes) - new_bytes = path.read_bytes() - - # Reset disk to prior state; the in-memory checklist keeps the marker item - # (with fixed timestamps) so the save-under-test reproduces new_bytes exactly. - if prior_bytes is not None: - path.write_bytes(prior_bytes) - else: - path.unlink() - return prior_bytes, new_bytes, checklist - - -@pytest.mark.parametrize("overwrite", [True, False], ids=["overwrite", "new_file"]) -def test_save_torn_direct_write_is_atomic(monkeypatch, tmp_path, overwrite): - path = _set_journal(monkeypatch, tmp_path) - prior_bytes, new_bytes, checklist = _prepare(path, overwrite=overwrite) - - real_write_text = pathlib.Path.write_text - - def torn_write_text(self, data, *args, **kwargs): - # Only sabotage the checklist's own whole-file write; pass everything - # else through untouched. - if isinstance(data, str) and MARKER in data: - with open(self, "w", encoding="utf-8") as handle: - handle.write(data[:-3]) # truncated -> torn final JSON line - raise OSError("simulated crash mid-write") - return real_write_text(self, data, *args, **kwargs) - - monkeypatch.setattr(pathlib.Path, "write_text", torn_write_text) - - try: - checklist.save() - except OSError: - pass # the simulated crash (pre-fix path) - - # All-or-nothing: prior intact, or fully-new โ€” never a torn partial. - if path.exists(): - assert path.read_bytes() in (prior_bytes, new_bytes) - else: - assert not overwrite # only the new-file case may be absent - assert list(path.parent.glob(".tmp_*")) == [] - - -@pytest.mark.parametrize("overwrite", [True, False], ids=["overwrite", "new_file"]) -def test_save_interrupted_atomic_replace_preserves_prior( - monkeypatch, tmp_path, overwrite -): - path = _set_journal(monkeypatch, tmp_path) - prior_bytes, _new_bytes, checklist = _prepare(path, overwrite=overwrite) - - def boom(src, dst): - raise OSError("simulated crash before rename") - - monkeypatch.setattr("solstone.think.journal_io.atomic.os.replace", boom) - - with pytest.raises(OSError): - checklist.save() - - if overwrite: - assert path.read_bytes() == prior_bytes - else: - assert not path.exists() - assert list(path.parent.glob(".tmp_*")) == [] - - -def test_save_preserves_exact_bytes(monkeypatch, tmp_path): - path = _set_journal(monkeypatch, tmp_path) - - checklist = todo.TodoChecklist.load(DAY, FACET) - checklist.append_entry("cafรฉ โœ… \U0001f44d") # accents + emoji - raw = path.read_bytes() - - # Non-ASCII is stored literally as UTF-8, never \uXXXX-escaped. - assert "cafรฉ โœ… \U0001f44d".encode("utf-8") in raw - assert b"\\u" not in raw - # Round-trips back to the same text. - record = json.loads(raw.decode("utf-8").splitlines()[0]) - assert record["text"] == "cafรฉ โœ… \U0001f44d" - - # Empty checklist -> empty file. - empty = todo.TodoChecklist( - day=DAY, - facet="work", - path=todo.todo_file_path(DAY, "work"), - items=[], - exists=False, - ) - empty.save() - assert empty.path.read_bytes() == b"" diff --git a/solstone/apps/todos/tests/test_mutation_origin.py b/solstone/apps/todos/tests/test_mutation_origin.py deleted file mode 100644 index 9496f54d2..000000000 --- a/solstone/apps/todos/tests/test_mutation_origin.py +++ /dev/null @@ -1,110 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Static guard: todos mutations only originate inside ``locked_modify``. - -Scans the todos ``call.py`` and ``routes.py`` surfaces and asserts that every -call to a mutating ``TodoChecklist`` method is lexically nested inside a -function passed as the ``modify_fn`` of a ``locked_modify(...)`` call. This is -the invariant that keeps every read-modify-write under the exclusive lock. -""" - -from __future__ import annotations - -import ast -from pathlib import Path - -import pytest - -MUTATING_METHODS = frozenset( - { - "append_entry", - "mark_done", - "mark_undone", - "cancel_entry", - "update_entry_text", - "save", - } -) - -APP_DIR = Path(__file__).resolve().parent.parent # solstone/apps/todos -TARGET_FILES = [APP_DIR / "call.py", APP_DIR / "routes.py"] - - -def _locked_modify_fn_names(tree: ast.AST) -> set[str]: - """Return names of functions passed as ``modify_fn`` to locked_modify.""" - names: set[str] = set() - for node in ast.walk(tree): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "locked_modify" - ): - arg: ast.expr | None = node.args[2] if len(node.args) >= 3 else None - for keyword in node.keywords: - if keyword.arg == "modify_fn": - arg = keyword.value - if isinstance(arg, ast.Name): - names.add(arg.id) - return names - - -def _parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]: - parents: dict[ast.AST, ast.AST] = {} - for parent in ast.walk(tree): - for child in ast.iter_child_nodes(parent): - parents[child] = parent - return parents - - -def _enclosing_func( - parents: dict[ast.AST, ast.AST], node: ast.AST -) -> ast.FunctionDef | ast.AsyncFunctionDef | None: - current = parents.get(node) - while current is not None: - if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): - return current - current = parents.get(current) - return None - - -def _violations(path: Path) -> list[tuple[str, str, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - parents = _parent_map(tree) - locked = _locked_modify_fn_names(tree) - - bad: list[tuple[str, str, int]] = [] - for node in ast.walk(tree): - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr in MUTATING_METHODS - ): - enclosing = _enclosing_func(parents, node) - if enclosing is None or enclosing.name not in locked: - bad.append((path.name, node.func.attr, node.lineno)) - return bad - - -@pytest.mark.parametrize("path", TARGET_FILES, ids=lambda p: p.name) -def test_no_unlocked_todo_mutation(path: Path) -> None: - assert _violations(path) == [] - - -def test_guard_flags_unlocked_mutation(tmp_path: Path) -> None: - source = "def handler(cl):\n cl.append_entry('x')\n" - fake = tmp_path / "fake_surface.py" - fake.write_text(source, encoding="utf-8") - assert _violations(fake) != [] - - -def test_guard_allows_locked_mutation(tmp_path: Path) -> None: - source = ( - "def cmd():\n" - " def _add(cl):\n" - " return cl.append_entry('x')\n" - " Obj.locked_modify(d, f, _add)\n" - ) - ok = tmp_path / "ok_surface.py" - ok.write_text(source, encoding="utf-8") - assert _violations(ok) == [] diff --git a/solstone/apps/todos/tests/test_render_at_scale.py b/solstone/apps/todos/tests/test_render_at_scale.py deleted file mode 100644 index 9739ab860..000000000 --- a/solstone/apps/todos/tests/test_render_at_scale.py +++ /dev/null @@ -1,328 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from html.parser import HTMLParser -from pathlib import Path - -import pytest - -from solstone.apps.todos import copy as todos_copy -from solstone.convey import create_app - -CHECK_EMOJI = "\u2705" -STAR_KEYCAP = "*\ufe0f\u20e3" -ZERO_KEYCAP = "0\ufe0f\u20e3" - - -class TodosHTMLParser(HTMLParser): - def __init__(self) -> None: - super().__init__() - self.todo_rows: list[dict] = [] - self.overflow_buttons: list[dict] = [] - self.facet_sections: list[dict] = [] - self.empty_states: list[dict] = [] - self._current_button: dict | None = None - self._in_todo_text = False - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - attr_map = {key: value or "" for key, value in attrs} - classes = attr_map.get("class", "").split() - - if tag == "li" and "todo-row" in classes and "data-todo-row" in attr_map: - self.todo_rows.append({"classes": classes, "attrs": attr_map, "text": ""}) - elif tag == "button" and "todo-overflow-btn" in classes: - button = {"attrs": attr_map, "text": ""} - self.overflow_buttons.append(button) - self._current_button = button - elif tag == "span" and "todo-text" in classes: - self._in_todo_text = True - elif tag == "div" and "facet-section" in classes: - self.facet_sections.append({"classes": classes, "attrs": attr_map}) - elif tag == "div" and "todo-facet-empty" in classes: - self.empty_states.append({"classes": classes, "attrs": attr_map}) - - def handle_data(self, data: str) -> None: - if self._current_button is not None: - self._current_button["text"] += data - if self._in_todo_text and self.todo_rows: - self.todo_rows[-1]["text"] += data - - def handle_endtag(self, tag: str) -> None: - if tag == "span": - self._in_todo_text = False - if tag == "button": - self._current_button = None - - -def _parse(html: str) -> TodosHTMLParser: - parser = TodosHTMLParser() - parser.feed(html) - return parser - - -@pytest.fixture -def todos_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - def _create(): - app = create_app(journal=str(tmp_path)) - app.config["TESTING"] = True - client = app.test_client() - with client.session_transaction() as session: - session["logged_in"] = True - session.permanent = True - return client - - return _create - - -def _ensure_facet(root: Path, facet: str) -> None: - facet_dir = root / "facets" / facet - facet_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps( - { - "title": facet.title(), - "description": f"{facet} facet", - "color": "#6b7280", - } - ) - + "\n", - encoding="utf-8", - ) - - -def _write_todos( - root: Path, - facet: str, - day: str, - *, - incomplete: int = 0, - completed: int = 0, -) -> None: - _ensure_facet(root, facet) - todos_dir = root / "facets" / facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - entries = [ - {"text": f"Open task {index}", "created_at": 1704067200000 + index} - for index in range(1, incomplete + 1) - ] - entries.extend( - { - "text": f"Done task {index}", - "completed": True, - "created_at": 1704077200000 + index, - } - for index in range(1, completed + 1) - ) - lines = [json.dumps(entry, ensure_ascii=False) for entry in entries] - (todos_dir / f"{day}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") - - -def _get_page(client, day: str) -> tuple[str, TodosHTMLParser]: - response = client.get(f"/app/todos/{day}") - assert response.status_code == 200 - html = response.get_data(as_text=True) - return html, _parse(html) - - -def test_initial_render_caps_at_budget_per_facet(tmp_path: Path, todos_client) -> None: - day = "20260415" - _write_todos(tmp_path, "personal", day, incomplete=50, completed=10) - _html, parser = _get_page(todos_client(), day) - - incomplete_rows = [ - row for row in parser.todo_rows if "completed" not in row["classes"] - ] - completed_rows = [row for row in parser.todo_rows if "completed" in row["classes"]] - assert len(incomplete_rows) == 30 - assert len(completed_rows) == 5 - - -def test_small_facet_renders_all_no_overflow(tmp_path: Path, todos_client) -> None: - day = "20260416" - _write_todos(tmp_path, "personal", day, incomplete=10, completed=3) - _html, parser = _get_page(todos_client(), day) - - assert len(parser.todo_rows) == 13 - assert parser.overflow_buttons == [] - - -def test_overflow_button_exact_text_and_aria(tmp_path: Path, todos_client) -> None: - client = todos_client() - - _write_todos(tmp_path, "work", "20260417", incomplete=50) - _html, parser = _get_page(client, "20260417") - incomplete_button = parser.overflow_buttons[0] - assert incomplete_button["attrs"]["data-section"] == "incomplete" - assert incomplete_button["text"].strip() == "Show 20 more" - assert incomplete_button["attrs"]["aria-label"] == "Show 20 more todos in work" - - _write_todos(tmp_path, "work", "20260418", incomplete=31) - _html, parser = _get_page(client, "20260418") - assert parser.overflow_buttons[0]["text"].strip() == "Show 1 more" - - _write_todos(tmp_path, "work", "20260419", completed=10) - _html, parser = _get_page(client, "20260419") - completed_button = parser.overflow_buttons[0] - assert completed_button["attrs"]["data-section"] == "completed" - assert completed_button["text"].strip() == "Show 5 more completed" - assert ( - completed_button["attrs"]["aria-label"] == "Show 5 more completed todos in work" - ) - - _write_todos(tmp_path, "work", "20260420", incomplete=30, completed=5) - _html, parser = _get_page(client, "20260420") - assert parser.overflow_buttons == [] - - -def test_facet_totals_in_data_attributes(tmp_path: Path, todos_client) -> None: - day = "20260421" - _write_todos(tmp_path, "personal", day, incomplete=50, completed=10) - _html, parser = _get_page(todos_client(), day) - - section = parser.facet_sections[0] - assert section["attrs"]["data-incomplete-total"] == "50" - assert section["attrs"]["data-completed-total"] == "10" - - -def test_emoji_and_pending_text_use_totals(tmp_path: Path, todos_client) -> None: - client = todos_client() - - _write_todos(tmp_path, "personal", "20260422", incomplete=50) - html, parser = _get_page(client, "20260422") - assert STAR_KEYCAP in html - assert "0 of 50 done" in html - assert "collapsed" not in parser.facet_sections[0]["classes"] - - _write_todos(tmp_path, "personal", "20260423", completed=35) - html, parser = _get_page(client, "20260423") - assert CHECK_EMOJI in html - assert "all done" in html - assert "collapsed" in parser.facet_sections[0]["classes"] - - _ensure_facet(tmp_path, "personal") - html, parser = _get_page(client, "20260424") - assert ZERO_KEYCAP in html - assert parser.empty_states - assert "collapsed" in parser.facet_sections[0]["classes"] - - -def test_overflow_route_returns_fragment_of_li_rows( - tmp_path: Path, todos_client -) -> None: - day = "20260425" - _write_todos(tmp_path, "personal", day, incomplete=50) - response = todos_client().get(f"/app/todos/{day}/overflow/personal/incomplete") - body = response.get_data(as_text=True) - parser = _parse(body) - - assert response.status_code == 200 - assert response.mimetype == "text/html" - assert len(parser.todo_rows) == 20 - assert " None: - day = "20260426" - _write_todos(tmp_path, "personal", day, completed=10) - response = todos_client().get(f"/app/todos/{day}/overflow/personal/completed") - parser = _parse(response.get_data(as_text=True)) - - assert response.status_code == 200 - assert len(parser.todo_rows) == 5 - - -def test_overflow_route_empty_when_at_or_under_budget( - tmp_path: Path, todos_client -) -> None: - day = "20260427" - _write_todos(tmp_path, "personal", day, incomplete=10) - response = todos_client().get(f"/app/todos/{day}/overflow/personal/incomplete") - - assert response.status_code == 200 - assert response.get_data(as_text=True) == "" - - -def test_overflow_route_validates_inputs(tmp_path: Path, todos_client) -> None: - day = "20260428" - _write_todos(tmp_path, "personal", day, incomplete=50) - client = todos_client() - - assert ( - client.get("/app/todos/notadate/overflow/personal/incomplete").status_code - == 404 - ) - assert client.get(f"/app/todos/{day}/overflow/personal/bogus").status_code == 400 - assert ( - client.get(f"/app/todos/{day}/overflow/unknown/incomplete").status_code == 404 - ) - - -def test_copy_constants_used_in_template(tmp_path: Path, todos_client) -> None: - day = "20260429" - _write_todos(tmp_path, "personal", day, incomplete=50) - response = todos_client().get(f"/app/todos/{day}") - - expected = todos_copy.SHOW_MORE_INCOMPLETE.format(count=20) - assert expected.encode() in response.data - - -def test_count_stability_under_add_assertion_level( - tmp_path: Path, todos_client -) -> None: - day = "20260430" - _write_todos(tmp_path, "personal", day, incomplete=50) - _html, parser = _get_page(todos_client(), day) - - section = parser.facet_sections[0] - incomplete_rows = [ - row for row in parser.todo_rows if "completed" not in row["classes"] - ] - assert section["attrs"]["data-incomplete-total"] == "50" - assert parser.overflow_buttons[0]["text"].strip() == "Show 20 more" - assert len(incomplete_rows) == 30 - - -def test_completed_section_shows_most_recently_completed( - tmp_path: Path, todos_client -) -> None: - day = "20260501" - _write_todos(tmp_path, "personal", day, completed=10) - client = todos_client() - _html, parser = _get_page(client, day) - - completed_rows = [row for row in parser.todo_rows if "completed" in row["classes"]] - assert [row["text"] for row in completed_rows] == [ - f"Done task {i}" for i in range(6, 11) - ] - - overflow = client.get(f"/app/todos/{day}/overflow/personal/completed") - overflow_parser = _parse(overflow.get_data(as_text=True)) - assert [row["text"] for row in overflow_parser.todo_rows] == [ - f"Done task {i}" for i in range(1, 6) - ] - - -def test_overflow_fragment_first_row_is_first_hidden_item( - tmp_path: Path, todos_client -) -> None: - # Bug-1 focus contract stand-in: there is no JS test runner in CI, so the - # corrected focus logic (focus the first NEWLY-revealed row) cannot be - # asserted at runtime. It is validated server-side via the fragment's - # source-order contract: the first row in the overflow fragment is the - # first originally-hidden item, which is exactly the row the corrected - # focus handler lands on after insertion. - day = "20260502" - _write_todos(tmp_path, "personal", day, incomplete=35) - fragment = todos_client().get(f"/app/todos/{day}/overflow/personal/incomplete") - parser = _parse(fragment.get_data(as_text=True)) - - texts = [row["text"] for row in parser.todo_rows] - assert texts[0] == "Open task 31" - assert texts == [f"Open task {i}" for i in range(31, 36)] diff --git a/solstone/apps/todos/tests/test_routes.py b/solstone/apps/todos/tests/test_routes.py deleted file mode 100644 index 2b4a9b6b0..000000000 --- a/solstone/apps/todos/tests/test_routes.py +++ /dev/null @@ -1,251 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from solstone.apps.todos.todo import TodoChecklist, TodoItem -from solstone.convey import create_app -from solstone.convey.reasons import INVALID_REQUEST_VALUE -from solstone.think.call import call_app -from solstone.think.convey_client import ConveyClient - -runner = CliRunner() -STABLE_KEYS = ( - "text", - "nudge", - "created_at", - "completed", - "cancelled", - "cancelled_reason", - "moved_to", -) - - -@pytest.fixture -def todos_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - def _create(): - app = create_app(journal=str(tmp_path)) - app.config["TESTING"] = True - client = app.test_client() - with client.session_transaction() as session: - session["logged_in"] = True - session.permanent = True - return client - - return _create - - -def _ensure_facet(root: Path, facet: str) -> None: - facet_dir = root / "facets" / facet - facet_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps( - { - "title": facet.title(), - "description": f"{facet} facet", - "color": "#6b7280", - } - ) - + "\n", - encoding="utf-8", - ) - - -def _write_todo(root: Path, facet: str, day: str, entry: dict) -> Path: - _ensure_facet(root, facet) - todos_dir = root / "facets" / facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - path = todos_dir / f"{day}.jsonl" - path.write_text(json.dumps(entry, ensure_ascii=False) + "\n", encoding="utf-8") - return path - - -def _read_jsonl(path: Path) -> list[dict]: - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - - -def _stable_items(path: Path) -> list[dict]: - return [ - {key: item[key] for key in STABLE_KEYS if key in item} - for item in _read_jsonl(path) - ] - - -def _client_for_journal(journal: Path): - app = create_app(journal=str(journal)) - app.config["TESTING"] = True - client = app.test_client() - with client.session_transaction() as session: - session["logged_in"] = True - session.permanent = True - return client - - -def test_move_same_item_twice_via_route(tmp_path: Path, todos_client) -> None: - day = "20260415" - target_day = "20260416" - facet = "personal" - _write_todo( - tmp_path, - facet, - day, - {"text": "Move once", "created_at": 1704067200000}, - ) - - client = todos_client() - payload = {"target_day": target_day, "facet": facet, "index": 1} - - first = client.post(f"/app/todos/{day}/move", json=payload) - target_path = tmp_path / "facets" / facet / "todos" / f"{target_day}.jsonl" - assert first.status_code == 200 - assert [item["text"] for item in _read_jsonl(target_path)] == ["Move once"] - - second = client.post(f"/app/todos/{day}/move", json=payload) - assert second.status_code == 409 - assert [item["text"] for item in _read_jsonl(target_path)] == ["Move once"] - - -def test_move_route_partial_failure_preserves_source( - tmp_path: Path, todos_client, monkeypatch: pytest.MonkeyPatch -) -> None: - day = "20260417" - target_day = "20260418" - facet = "personal" - source_path = _write_todo( - tmp_path, - facet, - day, - {"text": "Partial route", "created_at": 1704067200000}, - ) - - def fail_cancel( - self: TodoChecklist, - item: TodoItem, - *, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ): - raise RuntimeError("cancel failed") - - monkeypatch.setattr(TodoChecklist, "_apply_cancel", fail_cancel) - - response = todos_client().post( - f"/app/todos/{day}/move", - json={"target_day": target_day, "facet": facet, "index": 1}, - ) - - target_path = tmp_path / "facets" / facet / "todos" / f"{target_day}.jsonl" - assert response.status_code == 409 - assert response.get_json()["reason_code"] == "operation_no_longer_available" - assert _read_jsonl(source_path)[0].get("cancelled") is None - assert _read_jsonl(target_path)[0]["text"] == "Partial route" - - -def test_done_api_rejects_cancelled_todo_without_writing( - tmp_path: Path, todos_client -) -> None: - day = "20260420" - path = _write_todo( - tmp_path, - "personal", - day, - {"text": "Cancelled task", "cancelled": True}, - ) - before = path.read_bytes() - - response = todos_client().post( - "/app/todos/api/done", - json={"day": day, "facet": "personal", "line_number": 1}, - ) - - assert response.status_code == 400 - body = response.get_json() - assert body["reason_code"] == INVALID_REQUEST_VALUE.code - assert body["detail"] == "Cannot complete a cancelled todo." - assert path.read_bytes() == before - - -def test_cancel_api_rejects_completed_todo_without_writing( - tmp_path: Path, todos_client -) -> None: - day = "20260421" - path = _write_todo( - tmp_path, - "personal", - day, - {"text": "Completed task", "completed": True}, - ) - before = path.read_bytes() - - response = todos_client().post( - "/app/todos/api/cancel", - json={"day": day, "facet": "personal", "line_number": 1}, - ) - - assert response.status_code == 400 - body = response.get_json() - assert body["reason_code"] == INVALID_REQUEST_VALUE.code - assert body["detail"] == "Cannot cancel a completed todo." - assert path.read_bytes() == before - - -def test_edit_move_route_cli_byte_parity( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - day = "20260419" - entry = { - "text": "Parity task", - "nudge": "20260419T10:30", - "created_at": 1704067200000, - } - - route_journal = tmp_path / "route" - _write_todo(route_journal, "work", day, entry) - _ensure_facet(route_journal, "personal") - monkeypatch.setenv("SOLSTONE_JOURNAL", str(route_journal)) - route_response = _client_for_journal(route_journal).post( - f"/app/todos/{day}", - data={ - "action": "edit", - "facet": "work", - "index": "1", - "text": "Parity task #personal", - }, - ) - assert route_response.status_code == 302 - - cli_journal = tmp_path / "cli" - _write_todo(cli_journal, "work", day, entry) - _ensure_facet(cli_journal, "personal") - monkeypatch.setenv("SOLSTONE_JOURNAL", str(cli_journal)) - convey_client = ConveyClient(session=_client_for_journal(cli_journal), base_url="") - monkeypatch.setattr("solstone.apps.todos.call.get_client", lambda: convey_client) - cli_result = runner.invoke( - call_app, - [ - "todos", - "move", - "1", - "--day", - day, - "--from", - "work", - "--to", - "personal", - ], - ) - assert cli_result.exit_code == 0, cli_result.output - - for facet in ("work", "personal"): - relpath = Path("facets") / facet / "todos" / f"{day}.jsonl" - assert _stable_items(route_journal / relpath) == _stable_items( - cli_journal / relpath - ) diff --git a/solstone/apps/todos/tests/test_todo.py b/solstone/apps/todos/tests/test_todo.py deleted file mode 100644 index f1702cd42..000000000 --- a/solstone/apps/todos/tests/test_todo.py +++ /dev/null @@ -1,1018 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Tests for the facet-scoped todo checklist system.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from solstone.apps.todos.todo import ( - TodoChecklist, - TodoEmptyTextError, - TodoItem, - TodoMovePartialError, - TodoNotMovableError, - TodoTerminalStateError, - find_cross_facet_matches, - get_facets_with_todos, - get_todos, - upcoming, -) - - -@pytest.fixture -def journal_root(tmp_path): - path = tmp_path / "journal" - path.mkdir() - return path - - -def _write_todos(root: Path, facet: str, day: str, items: list[dict]) -> Path: - """Write todos to facets/{facet}/todos/{day}.jsonl""" - todos_dir = root / "facets" / facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - todo_path = todos_dir / f"{day}.jsonl" - lines = [json.dumps(item, ensure_ascii=False) for item in items] - todo_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - return todo_path - - -def _read_todos(path: Path) -> list[dict]: - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - - -def test_get_todos_returns_none_when_missing(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - assert get_todos("20240101", "personal") is None - - -def test_get_todos_parses_basic_fields(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "personal", - "20240102", - [ - {"text": "Merge analytics PR", "nudge": "20240102T10:30"}, - {"text": "Project sync", "completed": True}, # no nudge - {"text": "Write retrospective notes"}, - ], - ) - - todos = get_todos("20240102", "personal") - assert todos is not None - assert len(todos) == 3 - - first = todos[0] - assert first["index"] == 1 - assert first["nudge"] == "20240102T10:30" - assert first["completed"] is False - assert first["text"] == "Merge analytics PR" - - second = todos[1] - assert second["completed"] is True - assert second["nudge"] is None - assert second["text"] == "Project sync" - - third = todos[2] - assert third["text"] == "Write retrospective notes" - assert third["index"] == 3 - - -def test_get_todos_handles_cancelled(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240103", - [ - {"text": "Optional experiment", "cancelled": True}, - {"text": "Address bug", "nudge": "20240103T14:45"}, - {"text": "Draft report", "completed": True}, - ], - ) - - todos = get_todos("20240103", "work") - assert todos is not None - assert len(todos) == 3 - - cancelled = todos[0] - assert cancelled["cancelled"] is True - assert cancelled["text"] == "Optional experiment" - - second = todos[1] - assert second["nudge"] == "20240103T14:45" - assert second["index"] == 2 - - third = todos[2] - assert third["completed"] is True - assert third["text"] == "Draft report" - - -def test_get_todos_ignores_blank_lines(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - # Write with blank lines mixed in - todos_dir = journal_root / "facets" / "personal" / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - todo_path = todos_dir / "20240104.jsonl" - todo_path.write_text( - '\n{"text": "First"}\n\n{"text": "Second"}\n', - encoding="utf-8", - ) - - todos = get_todos("20240104", "personal") - assert [item["index"] for item in todos] == [1, 2] - - -def test_todo_item_display_line(): - """Test TodoItem.display_line() formatting.""" - item = TodoItem( - index=1, text="Simple task", nudge=None, completed=False, cancelled=False - ) - assert item.display_line() == "[ ] Simple task" - - item_done = TodoItem( - index=2, text="Done task", nudge=None, completed=True, cancelled=False - ) - assert item_done.display_line() == "[x] Done task" - - item_cancelled = TodoItem( - index=3, text="Cancelled task", nudge=None, completed=False, cancelled=True - ) - assert item_cancelled.display_line() == "~~[cancelled] Cancelled task~~" - - item_with_nudge = TodoItem( - index=4, - text="Meeting", - nudge="20240101T14:30", - completed=False, - cancelled=False, - ) - assert "[ ] Meeting (" in item_with_nudge.display_line() - - item_cancelled_done = TodoItem( - index=5, text="Was done", nudge=None, completed=True, cancelled=True - ) - assert item_cancelled_done.display_line() == "~~[cancelled] Was done~~" - - -def test_todo_item_to_jsonl(): - """Test TodoItem.to_jsonl() serialization.""" - item = TodoItem(index=1, text="Task", nudge=None, completed=False, cancelled=False) - assert item.to_jsonl() == {"text": "Task"} - - item_full = TodoItem( - index=2, - text="Full task", - nudge="20240102T10:00", - completed=True, - cancelled=False, - ) - assert item_full.to_jsonl() == { - "text": "Full task", - "nudge": "20240102T10:00", - "completed": True, - } - - item_cancelled = TodoItem( - index=3, text="Cancelled", nudge=None, completed=False, cancelled=True - ) - assert item_cancelled.to_jsonl() == {"text": "Cancelled", "cancelled": True} - - -def test_todo_item_from_jsonl(): - """Test TodoItem.from_jsonl() parsing.""" - item = TodoItem.from_jsonl({"text": "Simple"}, 1) - assert item.index == 1 - assert item.text == "Simple" - assert item.completed is False - assert item.cancelled is False - assert item.nudge is None - - item_full = TodoItem.from_jsonl( - { - "text": "Full", - "nudge": "20240102T10:00", - "completed": True, - "cancelled": False, - }, - 2, - ) - assert item_full.nudge == "20240102T10:00" - assert item_full.completed is True - - -def test_todo_item_from_jsonl_legacy_time_with_day(): - """Legacy 'time' field should be converted when day context is provided.""" - item = TodoItem.from_jsonl({"text": "Legacy", "time": "10:00"}, 1, day="20240102") - assert item.nudge == "20240102T10:00" - - -class TestParseNudge: - """Tests for parse_nudge().""" - - def test_hhmm(self): - from solstone.apps.todos.todo import parse_nudge - - assert parse_nudge("15:00", "20260301") == "20260301T15:00" - - def test_now(self): - from solstone.apps.todos.todo import parse_nudge - - result = parse_nudge("now", "20260301") - assert result.startswith("20") - assert "T" in result - - def test_tomorrow(self): - from solstone.apps.todos.todo import parse_nudge - - assert parse_nudge("tomorrow 09:00", "20260301") == "20260302T09:00" - - def test_full_datetime(self): - from solstone.apps.todos.todo import parse_nudge - - assert parse_nudge("20260315T14:30", "20260301") == "20260315T14:30" - - def test_invalid(self): - from solstone.apps.todos.todo import parse_nudge - - with pytest.raises(ValueError): - parse_nudge("garbage", "20260301") - - -class TestFormatNudge: - """Tests for format_nudge().""" - - def test_past_minutes(self): - from datetime import datetime - - from solstone.apps.todos.todo import format_nudge - - now = datetime(2026, 3, 1, 15, 30) - assert format_nudge("20260301T15:00", now) == "30m ago" - - def test_past_hours(self): - from datetime import datetime - - from solstone.apps.todos.todo import format_nudge - - now = datetime(2026, 3, 1, 18, 0) - assert format_nudge("20260301T15:00", now) == "3h ago" - - def test_future_today(self): - from datetime import datetime - - from solstone.apps.todos.todo import format_nudge - - now = datetime(2026, 3, 1, 10, 0) - assert format_nudge("20260301T15:00", now) == "nudge 15:00" - - def test_future_tomorrow(self): - from datetime import datetime - - from solstone.apps.todos.todo import format_nudge - - now = datetime(2026, 3, 1, 10, 0) - assert format_nudge("20260302T09:00", now) == "tomorrow 09:00" - - -def test_upcoming_groups_future_days(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - # Create facet structure - (journal_root / "facets" / "personal").mkdir(parents=True) - (journal_root / "facets" / "personal" / "facet.json").write_text( - '{"title": "Personal"}', encoding="utf-8" - ) - - _write_todos( - journal_root, - "personal", - "20240105", - [ - {"text": "First future task"}, - {"text": "Completed future task", "completed": True}, - ], - ) - _write_todos( - journal_root, - "personal", - "20240106", - [ - {"text": "Another future task"}, - ], - ) - _write_todos( - journal_root, - "personal", - "20240103", - [ - {"text": "Past task"}, - ], - ) - - result = upcoming(today="20240104") - - expected = ( - "### Personal: 20240105\n" - "[ ] First future task\n" - "[x] Completed future task\n\n" - "### Personal: 20240106\n" - "[ ] Another future task" - ) - - assert result == expected - - -def test_upcoming_respects_limit(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - # Create facet structure - (journal_root / "facets" / "work").mkdir(parents=True) - (journal_root / "facets" / "work" / "facet.json").write_text( - '{"title": "Work"}', encoding="utf-8" - ) - - _write_todos( - journal_root, - "work", - "20240105", - [ - {"text": "Task one"}, - {"text": "Task two"}, - {"text": "Task three"}, - ], - ) - - result = upcoming(limit=2, today="20240104") - - expected = "### Work: 20240105\n[ ] Task one\n[ ] Task two" - - assert result == expected - - -def test_upcoming_excludes_cancelled(monkeypatch, journal_root): - """Cancelled todos should not appear in upcoming view.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal").mkdir(parents=True) - (journal_root / "facets" / "personal" / "facet.json").write_text( - '{"title": "Personal"}', encoding="utf-8" - ) - - _write_todos( - journal_root, - "personal", - "20240105", - [ - {"text": "Active task"}, - {"text": "Cancelled task", "cancelled": True}, - {"text": "Another active"}, - ], - ) - - result = upcoming(today="20240104") - - assert "Active task" in result - assert "Another active" in result - assert "Cancelled task" not in result - - -def test_upcoming_when_no_future_todos(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal").mkdir(parents=True) - - _write_todos( - journal_root, - "personal", - "20240102", - [ - {"text": "Existing task"}, - ], - ) - - result = upcoming(today="20240102") - - assert result == "No upcoming todos." - - -def test_upcoming_filters_by_facet(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - # Create multiple facets - for facet_name in ["personal", "work"]: - facet_dir = journal_root / "facets" / facet_name - facet_dir.mkdir(parents=True) - (facet_dir / "facet.json").write_text( - f'{{"title": "{facet_name.title()}"}}', encoding="utf-8" - ) - - _write_todos(journal_root, "personal", "20240105", [{"text": "Personal task"}]) - _write_todos(journal_root, "work", "20240105", [{"text": "Work task"}]) - - # Test filtering by facet - result = upcoming(facet="personal", today="20240104") - assert "Personal: 20240105" in result - assert "Personal task" in result - assert "Work task" not in result - - -def test_upcoming_aggregates_all_facets(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - # Create multiple facets - for facet_name in ["personal", "work"]: - facet_dir = journal_root / "facets" / facet_name - facet_dir.mkdir(parents=True) - (facet_dir / "facet.json").write_text( - f'{{"title": "{facet_name.title()}"}}', encoding="utf-8" - ) - - _write_todos(journal_root, "personal", "20240105", [{"text": "Personal task"}]) - _write_todos(journal_root, "work", "20240105", [{"text": "Work task"}]) - - # Test aggregation (facet=None) - result = upcoming(facet=None, today="20240104") - assert "Personal: 20240105" in result - assert "Work: 20240105" in result - assert "Personal task" in result - assert "Work task" in result - - -def test_checklist_append_entry(monkeypatch, journal_root): - """Test TodoChecklist.append_entry() creates valid JSONL.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - # Create facet directory - facets_dir = journal_root / "facets" / "work" - facets_dir.mkdir(parents=True) - - checklist = TodoChecklist.load("20240105", "work") - - # Test normal entry works - item = checklist.append_entry("Test task", nudge="20240105T10:30") - assert len(checklist.items) == 1 - assert item.text == "Test task" - assert item.nudge == "20240105T10:30" - - # Verify file contents - content = checklist.path.read_text(encoding="utf-8") - data = json.loads(content.strip()) - assert data["text"] == "Test task" - assert data["nudge"] == "20240105T10:30" - - -def test_checklist_cancel_entry(monkeypatch, journal_root): - """Test TodoChecklist.cancel_entry() soft-deletes items.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - _write_todos( - journal_root, - "personal", - "20240105", - [{"text": "Task to cancel"}], - ) - - checklist = TodoChecklist.load("20240105", "personal") - item = checklist.cancel_entry(1) - - assert item.cancelled is True - assert checklist.items[0].cancelled is True - - # Verify file was updated - content = checklist.path.read_text(encoding="utf-8") - data = json.loads(content.strip()) - assert data["cancelled"] is True - - -def test_mark_done_rejects_cancelled_item_without_writing_completed( - monkeypatch, journal_root -): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - path = _write_todos( - journal_root, - "personal", - "20240105", - [{"text": "x", "cancelled": True}], - ) - - checklist = TodoChecklist.load("20240105", "personal") - with pytest.raises( - TodoTerminalStateError, match=r"Cannot complete a cancelled todo\." - ): - checklist.mark_done(1) - - data = _read_todos(path) - assert data[0]["cancelled"] is True - assert "completed" not in data[0] - - -def test_mark_done_rejects_completed_item(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "personal", - "20240105", - [{"text": "Done already", "completed": True}], - ) - - checklist = TodoChecklist.load("20240105", "personal") - with pytest.raises(TodoTerminalStateError, match=r"Todo is already completed\."): - checklist.mark_done(1) - - -def test_cancel_entry_rejects_completed_item(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "personal", - "20240105", - [{"text": "Done already", "completed": True}], - ) - - checklist = TodoChecklist.load("20240105", "personal") - with pytest.raises( - TodoTerminalStateError, match=r"Cannot cancel a completed todo\." - ): - checklist.cancel_entry(1) - - -def test_cancel_entry_rejects_cancelled_item(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "personal", - "20240105", - [{"text": "Cancelled already", "cancelled": True}], - ) - - checklist = TodoChecklist.load("20240105", "personal") - with pytest.raises(TodoTerminalStateError, match=r"Todo is already cancelled\."): - checklist.cancel_entry(1) - - -def test_move_entry_cross_facet_same_day(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240105", - [ - { - "text": "Ship feature", - "nudge": "20240105T10:30", - "created_at": 1704067200000, - } - ], - ) - - created, cancelled = TodoChecklist.move_entry( - "20240105", "work", 1, "20240105", "personal" - ) - - assert created.text == "Ship feature" - assert created.nudge == "20240105T10:30" - assert created.created_at == 1704067200000 - assert cancelled.cancelled is True - assert cancelled.cancelled_reason == "moved_to_facet" - assert cancelled.moved_to == "personal" - source = _read_todos(journal_root / "facets" / "work" / "todos" / "20240105.jsonl") - dest = _read_todos( - journal_root / "facets" / "personal" / "todos" / "20240105.jsonl" - ) - assert source[0]["cancelled"] is True - assert source[0]["moved_to"] == "personal" - assert dest[0]["text"] == "Ship feature" - assert dest[0]["created_at"] == 1704067200000 - - -def test_move_entry_cross_day_same_facet_carries_completion(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "personal", - "20240105", - [ - { - "text": "Done thing", - "completed": True, - "created_at": 1704067200000, - } - ], - ) - - created, cancelled = TodoChecklist.move_entry( - "20240105", "personal", 1, "20240106", "personal" - ) - - assert created.completed is True - assert cancelled.completed is True - dest = _read_todos( - journal_root / "facets" / "personal" / "todos" / "20240106.jsonl" - ) - assert dest[0]["completed"] is True - - -def test_move_entry_text_override(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "work", "20240105", [{"text": "Old text"}]) - - created, cancelled = TodoChecklist.move_entry( - "20240105", "work", 1, "20240105", "personal", text="New text" - ) - - assert cancelled.text == "Old text" - assert created.text == "New text" - - -def test_move_entry_empty_text_override_preserves_source(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - source_path = _write_todos(journal_root, "work", "20240105", [{"text": "Old text"}]) - - with pytest.raises(TodoEmptyTextError): - TodoChecklist.move_entry("20240105", "work", 1, "20240105", "personal", text="") - - assert "cancelled" not in _read_todos(source_path)[0] - assert not ( - journal_root / "facets" / "personal" / "todos" / "20240105.jsonl" - ).exists() - - -def test_move_entry_rejects_identical_source_and_destination(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "personal", "20240105", [{"text": "Same place"}]) - - with pytest.raises(ValueError, match="identical"): - TodoChecklist.move_entry("20240105", "personal", 1, "20240105", "personal") - - -def test_move_entry_rejects_out_of_range(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "work", "20240105", [{"text": "Only one"}]) - - with pytest.raises(IndexError): - TodoChecklist.move_entry("20240105", "work", 2, "20240105", "personal") - - -def test_move_entry_rejects_cancelled_source(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240105", - [{"text": "Already moved", "cancelled": True}], - ) - - with pytest.raises(TodoNotMovableError, match="already cancelled"): - TodoChecklist.move_entry("20240105", "work", 1, "20240105", "personal") - - -def test_move_entry_partial_failure_preserves_source(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - source_path = _write_todos( - journal_root, - "work", - "20240105", - [{"text": "Keep source", "created_at": 1704067200000}], - ) - - def fail_cancel( - self: TodoChecklist, - item: TodoItem, - *, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ): - raise RuntimeError("cancel failed") - - monkeypatch.setattr(TodoChecklist, "_apply_cancel", fail_cancel) - - with pytest.raises(TodoMovePartialError) as exc_info: - TodoChecklist.move_entry("20240105", "work", 1, "20240105", "personal") - - assert exc_info.value.src_day == "20240105" - assert exc_info.value.src_facet == "work" - assert exc_info.value.dst_day == "20240105" - assert exc_info.value.dst_facet == "personal" - assert exc_info.value.new_index == 1 - source = _read_todos(source_path) - dest = _read_todos( - journal_root / "facets" / "personal" / "todos" / "20240105.jsonl" - ) - assert source[0].get("cancelled") is None - assert dest[0]["text"] == "Keep source" - - -def test_checklist_display_includes_cancelled_with_strikethrough( - monkeypatch, journal_root -): - """Test TodoChecklist.display() always includes cancelled items with strikethrough.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - _write_todos( - journal_root, - "personal", - "20240105", - [ - {"text": "Active task"}, - {"text": "Cancelled task", "cancelled": True}, - {"text": "Another active"}, - ], - ) - - checklist = TodoChecklist.load("20240105", "personal") - - # All items included, cancelled ones have strikethrough - display = checklist.display() - assert "1: [ ] Active task" in display - assert "2: ~~[cancelled] Cancelled task~~" in display - assert "3: [ ] Another active" in display - - -def test_get_facets_with_todos(monkeypatch, journal_root): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - # Create todos in multiple facets - _write_todos(journal_root, "personal", "20240105", [{"text": "Personal task"}]) - _write_todos(journal_root, "work", "20240105", [{"text": "Work task"}]) - _write_todos(journal_root, "hobby", "20240106", [{"text": "Hobby task"}]) - - # Test getting facets for a specific day - facets_20240105 = get_facets_with_todos("20240105") - assert sorted(facets_20240105) == ["personal", "work"] - - facets_20240106 = get_facets_with_todos("20240106") - assert facets_20240106 == ["hobby"] - - facets_20240107 = get_facets_with_todos("20240107") - assert facets_20240107 == [] - - -# --- Timestamp tests --- - - -def test_todo_item_timestamps_on_creation(monkeypatch, journal_root): - """Test that timestamps are set when creating a new todo.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - # Create facet directory - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - before = int(time.time() * 1000) - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("New task") - after = int(time.time() * 1000) - - assert item.created_at is not None - assert item.updated_at is not None - assert before <= item.created_at <= after - assert before <= item.updated_at <= after - assert item.created_at == item.updated_at # Same on creation - - -def test_todo_item_updated_at_changes_on_mark_done(monkeypatch, journal_root): - """Test that updated_at changes when marking todo complete.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("Task to complete") - original_created = item.created_at - original_updated = item.updated_at - - time.sleep(0.01) # Ensure time passes - - updated_item = checklist.mark_done(item.index) - - assert updated_item.created_at == original_created # Unchanged - assert updated_item.updated_at > original_updated - - -def test_todo_item_updated_at_changes_on_mark_undone(monkeypatch, journal_root): - """Test that updated_at changes when marking todo incomplete.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("Task to uncomplete") - checklist.mark_done(item.index) - - time.sleep(0.01) - - reloaded = TodoChecklist.load("20240110", "personal") - original_updated = reloaded.items[0].updated_at - original_created = reloaded.items[0].created_at - - time.sleep(0.01) - - updated_item = reloaded.mark_undone(item.index) - - assert updated_item.created_at == original_created - assert updated_item.updated_at > original_updated - - -def test_todo_item_updated_at_changes_on_cancel(monkeypatch, journal_root): - """Test that updated_at changes when cancelling a todo.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("Task to cancel") - original_created = item.created_at - original_updated = item.updated_at - - time.sleep(0.01) - - cancelled_item = checklist.cancel_entry(item.index) - - assert cancelled_item.created_at == original_created - assert cancelled_item.updated_at > original_updated - - -def test_todo_item_updated_at_changes_on_text_update(monkeypatch, journal_root): - """Test that updated_at changes when updating todo text.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("Original text") - original_created = item.created_at - original_updated = item.updated_at - - time.sleep(0.01) - - updated_item = checklist.update_entry_text(item.index, "Updated text") - - assert updated_item.created_at == original_created - assert updated_item.updated_at > original_updated - - -def test_todo_item_timestamps_serialization(monkeypatch, journal_root): - """Test that timestamps are properly serialized to and from JSONL.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("Persistent task") - - # Verify timestamps in to_jsonl output - jsonl = item.to_jsonl() - assert "created_at" in jsonl - assert "updated_at" in jsonl - assert jsonl["created_at"] == item.created_at - assert jsonl["updated_at"] == item.updated_at - - # Verify timestamps survive round-trip - reloaded = TodoChecklist.load("20240110", "personal") - assert len(reloaded.items) == 1 - assert reloaded.items[0].created_at == item.created_at - assert reloaded.items[0].updated_at == item.updated_at - - -def test_todo_item_timestamps_in_as_dict(monkeypatch, journal_root): - """Test that timestamps are included in as_dict() output.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - item = checklist.append_entry("API task") - - d = item.as_dict() - assert "created_at" in d - assert "updated_at" in d - assert d["created_at"] == item.created_at - assert d["updated_at"] == item.updated_at - - -def test_todo_item_backward_compatibility_no_timestamps(monkeypatch, journal_root): - """Test loading files without timestamps (backward compatibility).""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - - # Write old-format todos without timestamps - _write_todos( - journal_root, - "personal", - "20240110", - [ - {"text": "Old task"}, - {"text": "Old completed", "completed": True}, - ], - ) - - checklist = TodoChecklist.load("20240110", "personal") - assert len(checklist.items) == 2 - - # Old items have None timestamps - assert checklist.items[0].created_at is None - assert checklist.items[0].updated_at is None - assert checklist.items[1].created_at is None - assert checklist.items[1].updated_at is None - - -def test_append_entry_preserves_created_at(monkeypatch, journal_root): - """Test that append_entry can preserve a provided created_at timestamp.""" - import time - - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - (journal_root / "facets" / "personal" / "todos").mkdir(parents=True) - - checklist = TodoChecklist.load("20240110", "personal") - original_created = 1700000000000 # Fixed timestamp in the past - - before = int(time.time() * 1000) - item = checklist.append_entry("Moved task", created_at=original_created) - after = int(time.time() * 1000) - - # created_at preserved from argument - assert item.created_at == original_created - # updated_at is current time - assert before <= item.updated_at <= after - - -class TestFindCrossFacetMatches: - """Tests for find_cross_facet_matches() cross-facet duplicate detection.""" - - def test_detects_duplicate_in_other_facet(self, monkeypatch, journal_root): - """Exact duplicate in another facet is detected.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "work", "20240102", [{"text": "Draft Q1 plan"}]) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 1 - assert matches[0]["score"] == 100.0 - assert matches[0]["facet"] == "work" - assert matches[0]["day"] == "20240102" - assert matches[0]["line"] == 1 - - def test_detects_fuzzy_match(self, monkeypatch, journal_root): - """Fuzzy match above threshold is detected.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240102", - [{"text": "Draft Q1 plan doc"}], - ) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) >= 1 - assert matches[0]["score"] >= 70.0 - - def test_no_false_positives(self, monkeypatch, journal_root): - """Unrelated todos in other facets are not flagged.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "work", "20240102", [{"text": "Buy groceries"}]) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 0 - - def test_excludes_own_facet(self, monkeypatch, journal_root): - """Todos in the requesting facet are excluded.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "personal", "20240102", [{"text": "Draft Q1 plan"}]) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 0 - - def test_excludes_cancelled(self, monkeypatch, journal_root): - """Cancelled todos are not matched.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240102", - [{"text": "Draft Q1 plan", "cancelled": True}], - ) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 0 - - def test_excludes_completed(self, monkeypatch, journal_root): - """Completed todos are not matched.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos( - journal_root, - "work", - "20240102", - [{"text": "Draft Q1 plan", "completed": True}], - ) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 0 - - def test_day_range_covers_adjacent_days(self, monkeypatch, journal_root): - """Matches within ยฑ1 day window are detected.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - _write_todos(journal_root, "work", "20240101", [{"text": "Draft Q1 plan"}]) - _write_todos(journal_root, "work", "20240103", [{"text": "Draft Q1 plan"}]) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert len(matches) == 2 - - def test_empty_journal_returns_empty(self, monkeypatch, journal_root): - """No facets returns empty list.""" - monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_root)) - matches = find_cross_facet_matches("Draft Q1 plan", "20240102", "personal") - assert matches == [] diff --git a/solstone/apps/todos/todo.py b/solstone/apps/todos/todo.py deleted file mode 100644 index 484d0efa0..000000000 --- a/solstone/apps/todos/todo.py +++ /dev/null @@ -1,978 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Todo checklist utilities shared across think components. - -Todos are stored as JSONL files with one JSON object per line. Line number (1-indexed) -serves as the stable todo ID since todos are never removed, only cancelled. -""" - -from __future__ import annotations - -import json -import logging -import re -from dataclasses import dataclass -from datetime import datetime, timedelta -from pathlib import Path -from typing import Any - -from solstone.think.facets import get_facets -from solstone.think.journal_io import atomic_replace, hold_lock -from solstone.think.utils import get_journal, now_ms - -__all__ = [ - "TodoChecklist", - "TodoItem", - "TodoError", - "TodoEmptyTextError", - "TodoNotMovableError", - "TodoMovePartialError", - "find_cross_facet_matches", - "parse_nudge", - "format_nudge", - "get_todos", - "todo_file_path", - "validate_line_number", - "upcoming", - "get_facets_with_todos", - "get_todo_days_in_range", -] - - -def parse_nudge(value: str, day: str) -> str: - """Parse nudge input and return normalized YYYYMMDDTHH:MM string. - - Args: - value: One of: "HH:MM", "now", "tomorrow HH:MM", "YYYYMMDDTHH:MM". - day: Current day in YYYYMMDD format, used to resolve relative times. - - Returns: - Normalized nudge string in YYYYMMDDTHH:MM format. - - Raises: - ValueError: If the input format is not recognized. - """ - value = value.strip() - - # "now" โ€” use current time - if value.lower() == "now": - now = datetime.now() - return now.strftime("%Y%m%dT%H:%M") - - # "YYYYMMDDTHH:MM" โ€” already normalized, validate - if len(value) == 14 and value[8] == "T": - date_part = value[:8] - time_part = value[9:] - try: - datetime.strptime(f"{date_part} {time_part}", "%Y%m%d %H:%M") - except ValueError: - raise ValueError(f"invalid nudge datetime: {value}") from None - return value - - # "tomorrow HH:MM" - if value.lower().startswith("tomorrow"): - time_str = value[len("tomorrow") :].strip() - try: - parsed = datetime.strptime(time_str, "%H:%M") - except ValueError: - raise ValueError( - f"invalid nudge time after 'tomorrow': {time_str}" - ) from None - day_date = datetime.strptime(day, "%Y%m%d") + timedelta(days=1) - return f"{day_date.strftime('%Y%m%d')}T{parsed.strftime('%H:%M')}" - - # "HH:MM" โ€” use the provided day - try: - parsed = datetime.strptime(value, "%H:%M") - except ValueError: - raise ValueError(f"unrecognized nudge format: {value}") from None - return f"{day}T{parsed.strftime('%H:%M')}" - - -def format_nudge(nudge: str, now: datetime | None = None) -> str: - """Format a nudge datetime for display. - - Past nudges show age: "3h ago", "yesterday", "2d ago". - Future nudges show scheduled time: "15:00", "tomorrow 09:00". - - Args: - nudge: Nudge string in YYYYMMDDTHH:MM format. - now: Current datetime (defaults to datetime.now()). - - Returns: - Human-readable nudge display string. - """ - if now is None: - now = datetime.now() - - try: - nudge_dt = datetime.strptime(nudge, "%Y%m%dT%H:%M") - except ValueError: - return nudge # fallback: return raw - - delta = now - nudge_dt - total_seconds = delta.total_seconds() - - if total_seconds > 0: - # Past nudge - minutes = int(total_seconds // 60) - hours = int(total_seconds // 3600) - days = int(total_seconds // 86400) - if minutes < 1: - return "just now" - if minutes < 60: - return f"{minutes}m ago" - if hours < 24: - return f"{hours}h ago" - if days == 1: - return "yesterday" - return f"{days}d ago" - - # Future nudge - nudge_day = nudge[:8] - now_day = now.strftime("%Y%m%d") - time_str = nudge[9:] - if nudge_day == now_day: - return f"nudge {time_str}" - # Check if tomorrow - tomorrow = now + timedelta(days=1) - if nudge_day == tomorrow.strftime("%Y%m%d"): - return f"tomorrow {time_str}" - # Further out โ€” show date - return f"{nudge_dt.strftime('%b %-d')} {time_str}" - - -class TodoError(Exception): - """Base exception for todo checklist operations.""" - - -class TodoEmptyTextError(TodoError): - """Raised when attempting to add an empty todo entry.""" - - def __init__(self) -> None: - super().__init__("todo text cannot be empty") - - -class TodoNotMovableError(TodoError): - """Raised when a todo cannot be moved from its source.""" - - line_number: int - - def __init__(self, line_number: int) -> None: - self.line_number = line_number - super().__init__(f"todo {line_number} is already cancelled") - - -class TodoMovePartialError(TodoError): - """Raised when a move append succeeded but source finalization failed.""" - - src_day: str - src_facet: str - line_number: int - dst_day: str - dst_facet: str - new_index: int - - def __init__( - self, - src_day: str, - src_facet: str, - line_number: int, - dst_day: str, - dst_facet: str, - new_index: int, - ) -> None: - self.src_day = src_day - self.src_facet = src_facet - self.line_number = line_number - self.dst_day = dst_day - self.dst_facet = dst_facet - self.new_index = new_index - super().__init__( - f"todo {line_number} was appended to {dst_facet}/{dst_day} " - f"as line {new_index}, but the source could not be finalized" - ) - - -class TodoTerminalStateError(TodoError): - """Raised when a done/cancel transition is attempted on a terminal-state todo.""" - - -@dataclass(slots=True) -class TodoItem: - """Structured representation of a todo entry.""" - - index: int - text: str - nudge: str | None - completed: bool - cancelled: bool - cancelled_reason: str | None = None - moved_to: str | None = None - created_at: int | None = None - updated_at: int | None = None - notified: bool = False - - def as_dict(self) -> dict[str, object]: - """Return the item as a JSON-serializable dictionary.""" - data: dict[str, object] = { - "index": self.index, - "text": self.text, - "nudge": self.nudge, - "completed": self.completed, - "cancelled": self.cancelled, - "created_at": self.created_at, - "updated_at": self.updated_at, - "notified": self.notified, - } - if self.cancelled_reason is not None: - data["cancelled_reason"] = self.cancelled_reason - if self.moved_to is not None: - data["moved_to"] = self.moved_to - return data - - def to_jsonl(self) -> dict[str, Any]: - """Return the item as a JSONL-compatible dictionary for storage.""" - data: dict[str, Any] = {"text": self.text} - if self.nudge: - data["nudge"] = self.nudge - if self.completed: - data["completed"] = True - if self.cancelled: - data["cancelled"] = True - if self.cancelled_reason is not None: - data["cancelled_reason"] = self.cancelled_reason - if self.moved_to is not None: - data["moved_to"] = self.moved_to - if self.notified: - data["notified"] = True - if self.created_at is not None: - data["created_at"] = self.created_at - if self.updated_at is not None: - data["updated_at"] = self.updated_at - return data - - @classmethod - def from_jsonl( - cls, data: dict[str, Any], index: int, day: str | None = None - ) -> "TodoItem": - """Create a TodoItem from a JSONL dictionary.""" - nudge = data.get("nudge") - if nudge is None and data.get("time") and day: - nudge = f"{day}T{data['time']}" - return cls( - index=index, - text=data.get("text", ""), - nudge=nudge, - completed=data.get("completed", False), - cancelled=data.get("cancelled", False), - cancelled_reason=data.get("cancelled_reason"), - moved_to=data.get("moved_to"), - created_at=data.get("created_at"), - updated_at=data.get("updated_at"), - notified=data.get("notified", False), - ) - - def display_line(self) -> str: - """Return human-readable display format for this todo.""" - if self.cancelled: - checkbox = "[cancelled]" - elif self.completed: - checkbox = "[x]" - else: - checkbox = "[ ]" - - text = self.text - if self.nudge: - text = f"{text} ({format_nudge(self.nudge)})" - - if self.cancelled: - return f"~~{checkbox} {text}~~" - - return f"{checkbox} {text}" - - -@dataclass(slots=True) -class TodoChecklist: - """In-memory representation of a day's todo checklist.""" - - day: str - facet: str - path: Path - items: list[TodoItem] - exists: bool - - def _validated_text(self, text: str) -> str: - """Validate and clean todo text.""" - body = text.strip() - if not body: - raise TodoEmptyTextError() - return body - - def _get_item(self, line_number: int) -> tuple[int, TodoItem]: - """Get item by line number, returning (index, item).""" - validate_line_number(line_number, len(self.items)) - index = line_number - 1 - return index, self.items[index] - - @classmethod - def load(cls, day: str, facet: str) -> "TodoChecklist": - """Load checklist entries for ``day`` and ``facet``. - - Args: - day: Journal day in ``YYYYMMDD`` format. - facet: Facet name (e.g., "personal", "work"). - - Returns: - TodoChecklist instance with items loaded from disk, or empty if file doesn't exist. - """ - path = todo_file_path(day, facet) - exists = path.is_file() - items: list[TodoItem] = [] - - if exists: - try: - text = path.read_text(encoding="utf-8") - item_index = 0 - for line in text.splitlines(): - line = line.strip() - if not line: - continue - item_index += 1 - try: - data = json.loads(line) - items.append(TodoItem.from_jsonl(data, item_index, day=day)) - except json.JSONDecodeError: - logging.debug( - "Skipping malformed JSONL line %d in %s", item_index, path - ) - continue - except OSError as exc: - logging.debug("Failed reading todos from %s: %s", path, exc) - exists = False - - return cls(day=day, facet=facet, path=path, items=items, exists=exists) - - @classmethod - def locked_modify( - cls, - day: str, - facet: str, - modify_fn: Any, - ) -> Any: - """Perform a locked load-modify-save on a todo checklist. - - Acquires the exclusive sidecar lock via the shared ``hold_lock`` - primitive, loads current state, applies ``modify_fn`` (which receives a - fresh ``TodoChecklist`` and calls save-triggering methods that persist - atomically inside the lock), and returns its result. - - Args: - day: Journal day in ``YYYYMMDD`` format. - facet: Facet name. - modify_fn: Called with a fresh TodoChecklist. Must return the value - to propagate to the caller. - - Returns: - Whatever ``modify_fn`` returns. - - Raises: - LockTimeout: if the lock is not acquired before the primitive's - default timeout. Owner entry points map this to their error - surface. - """ - path = todo_file_path(day, facet) - with hold_lock(path): - checklist = cls.load(day, facet) - return modify_fn(checklist) - - @classmethod - def move_entry( - cls, - src_day: str, - src_facet: str, - line_number: int, - dst_day: str, - dst_facet: str, - *, - text: str | None = None, - ) -> tuple[TodoItem, TodoItem]: - """Move a todo entry between todo files under ordered source/dest locks.""" - src_path = todo_file_path(src_day, src_facet) - dst_path = todo_file_path(dst_day, dst_facet) - if src_path == dst_path: - raise ValueError("todo move source and destination are identical") - - first, second = sorted((src_path, dst_path), key=str) - with hold_lock(first), hold_lock(second): - source = cls.load(src_day, src_facet) - _index, item = source._get_item(line_number) - if item.cancelled: - raise TodoNotMovableError(line_number) - - effective_text = item.text if text is None else text - dest = cls.load(dst_day, dst_facet) - created = dest.append_entry( - effective_text, - nudge=item.nudge, - created_at=item.created_at, - ) - - try: - if item.completed: - dest._apply_done(created) - cancelled = source._apply_cancel( - item, - cancelled_reason="moved_to_facet", - moved_to=dst_facet, - ) - except Exception as exc: - raise TodoMovePartialError( - src_day, - src_facet, - line_number, - dst_day, - dst_facet, - created.index, - ) from exc - - return created, cancelled - - def save(self) -> None: - """Persist the checklist to disk via durable atomic replace.""" - lines = [json.dumps(item.to_jsonl(), ensure_ascii=False) for item in self.items] - content = "\n".join(lines) - if lines: - content += "\n" - atomic_replace(self.path, content) - self.exists = True - - def display(self) -> str: - """Return checklist formatted for display with line numbers. - - All items are included. Cancelled items are shown with ~~strikethrough~~. - """ - if not self.items: - return "0: (no todos)" - - lines = [f"{item.index}: {item.display_line()}" for item in self.items] - return "\n".join(lines) - - def append_entry( - self, text: str, nudge: str | None = None, *, created_at: int | None = None - ) -> TodoItem: - """Append a new unchecked todo entry. - - Args: - text: Body of the todo item. - nudge: Optional nudge datetime in YYYYMMDDTHH:MM format. - created_at: Optional creation timestamp to preserve (e.g., when moving todos). - - Returns: - The newly created TodoItem. - """ - body = self._validated_text(text) - - now = now_ms() - item = TodoItem( - index=len(self.items) + 1, - text=body, - nudge=nudge, - completed=False, - cancelled=False, - created_at=created_at if created_at is not None else now, - updated_at=now, - ) - - self.items.append(item) - self.save() - return item - - def _apply_done(self, item: TodoItem) -> TodoItem: - """Mark an item complete without a terminal-state guard (internal).""" - item.completed = True - item.updated_at = now_ms() - self.save() - return item - - def _apply_cancel( - self, - item: TodoItem, - *, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ) -> TodoItem: - """Cancel an item without a terminal-state guard (internal).""" - item.cancelled = True - if cancelled_reason is not None: - item.cancelled_reason = cancelled_reason - if moved_to is not None: - item.moved_to = moved_to - item.updated_at = now_ms() - self.save() - return item - - def cancel_entry( - self, - line_number: int, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ) -> TodoItem: - """Cancel a todo entry (soft delete). - - Args: - line_number: 1-based index of the entry to cancel. - - Returns: - The cancelled TodoItem. - """ - _, item = self._get_item(line_number) - if item.cancelled: - raise TodoTerminalStateError("Todo is already cancelled.") - if item.completed: - raise TodoTerminalStateError("Cannot cancel a completed todo.") - return self._apply_cancel( - item, cancelled_reason=cancelled_reason, moved_to=moved_to - ) - - def mark_done(self, line_number: int) -> TodoItem: - """Mark a todo entry complete. - - Args: - line_number: 1-based index of the entry to mark as done. - - Returns: - The updated TodoItem. - """ - _, item = self._get_item(line_number) - if item.cancelled: - raise TodoTerminalStateError("Cannot complete a cancelled todo.") - if item.completed: - raise TodoTerminalStateError("Todo is already completed.") - return self._apply_done(item) - - def mark_undone(self, line_number: int) -> TodoItem: - """Mark a todo entry incomplete. - - Args: - line_number: 1-based index of the entry to mark as not done. - - Returns: - The updated TodoItem. - """ - _, item = self._get_item(line_number) - - item.completed = False - item.updated_at = now_ms() - self.save() - return item - - def update_entry_text(self, line_number: int, text: str) -> TodoItem: - """Replace the body text of an entry while keeping its completion state. - - Args: - line_number: 1-based index of the entry to update. - text: New text for the todo item. - - Returns: - The updated TodoItem. - """ - _, item = self._get_item(line_number) - body = self._validated_text(text) - - item.text = body - item.updated_at = now_ms() - self.save() - return item - - def get_item(self, line_number: int) -> TodoItem: - """Get a todo item by line number. - - Args: - line_number: 1-based index of the entry. - - Returns: - The TodoItem at that line number. - """ - _, item = self._get_item(line_number) - return item - - -def todo_file_path(day: str, facet: str) -> Path: - """Return the absolute path to ``facets/{facet}/todos/{day}.jsonl``. - - Args: - day: Journal day in ``YYYYMMDD`` format. - facet: Facet name (e.g., "personal", "work"). - - Returns: - Path to the facet-scoped todo file for the specified day. - """ - journal = get_journal() - return Path(journal) / "facets" / facet / "todos" / f"{day}.jsonl" - - -def validate_line_number(line_number: int, max_line: int) -> None: - """Ensure ``line_number`` is within ``[1, max_line]`` inclusive.""" - if line_number < 1 or line_number > max_line: - raise IndexError(f"line number {line_number} is out of range (1..{max_line})") - - -def get_todos(day: str, facet: str) -> list[dict[str, Any]] | None: - """Load todos for ``day`` and ``facet`` returning checklist metadata dictionaries. - - Args: - day: Journal day in ``YYYYMMDD`` format. - facet: Facet name (e.g., "personal", "work"). - - Returns: - List of parsed todo entries or ``None`` when the checklist does not - exist or cannot be read. - """ - try: - checklist = TodoChecklist.load(day, facet) - except OSError as exc: # pragma: no cover - filesystem failure - logging.debug("Failed reading todos for %s/%s: %s", facet, day, exc) - return None - - if not checklist.exists: - return None - - return [item.as_dict() for item in checklist.items] - - -def upcoming( - limit: int = 20, facet: str | None = None, *, today: str | None = None -) -> str: - """Return a markdown summary of upcoming todo entries. - - Args: - limit: Maximum number of todo items to include. - facet: Optional facet filter. When ``None`` aggregates todos from all facets. - today: Optional ``YYYYMMDD`` override for the current day, useful for testing. - - Returns: - Markdown with sections per facet and day. Format is ``### {Facet Title}: YYYYMMDD`` - followed by todo display lines. When no upcoming items exist the string - ``"No upcoming todos."`` is returned. - """ - if limit <= 0: - return "No upcoming todos." - - root = Path(get_journal()) - - today_str = today if today is not None else datetime.now().strftime("%Y%m%d") - - # Determine which facets to scan - facets_dir = root / "facets" - if not facets_dir.is_dir(): - return "No upcoming todos." - - try: - if facet is not None: - # Single facet mode - facet_paths = [(facet, facets_dir / facet)] - else: - # All facets mode - facet_paths = [(d.name, d) for d in facets_dir.iterdir() if d.is_dir()] - except OSError: # pragma: no cover - filesystem failure - return "No upcoming todos." - - if not facet_paths: - return "No upcoming todos." - - # Collect all todos across facets - all_todos: list[tuple[str, str, list[str]]] = [] # (facet, day, display_lines) - - for facet_name, facet_path in facet_paths: - todos_dir = facet_path / "todos" - if not todos_dir.is_dir(): - continue - - try: - todo_files = sorted( - f.stem - for f in todos_dir.iterdir() - if f.is_file() - and f.suffix == ".jsonl" - and len(f.stem) == 8 - and f.stem.isdigit() - and f.stem > today_str - ) - except OSError: # pragma: no cover - filesystem failure - continue - - for day_str in todo_files: - checklist = TodoChecklist.load(day_str, facet_name) - # Filter to non-cancelled items for display - display_lines = [ - item.display_line() for item in checklist.items if not item.cancelled - ] - if display_lines: - all_todos.append((facet_name, day_str, display_lines)) - - if not all_todos: - return "No upcoming todos." - - # Sort by day, then facet - all_todos.sort(key=lambda x: (x[1], x[0])) - - # Build output sections - remaining = limit - sections: list[str] = [] - - for facet_name, day_str, lines in all_todos: - # Get facet title for better display - try: - facets = get_facets() - facet_title = facets.get(facet_name, {}).get("title", facet_name.title()) - except (RuntimeError, KeyError): - facet_title = facet_name.title() - - day_lines: list[str] = [] - for line in lines: - day_lines.append(line) - remaining -= 1 - if remaining == 0: - break - - if day_lines: - section = "\n".join([f"### {facet_title}: {day_str}"] + day_lines) - sections.append(section) - - if remaining == 0: - break - - if not sections: - return "No upcoming todos." - - return "\n\n".join(sections) - - -def get_facets_with_todos(day: str) -> list[str]: - """Return a list of facet names that have todos for the given day. - - Args: - day: Journal day in ``YYYYMMDD`` format. - - Returns: - List of facet names that have todo files for the specified day. - Returns empty list if no facets have todos or if journal path is invalid. - """ - facets_dir = Path(get_journal()) / "facets" - - if not facets_dir.is_dir(): - return [] - - facets_with_todos: list[str] = [] - - try: - for facet_dir in facets_dir.iterdir(): - if not facet_dir.is_dir(): - continue - - todo_path = facet_dir / "todos" / f"{day}.jsonl" - if todo_path.is_file(): - facets_with_todos.append(facet_dir.name) - except OSError: # pragma: no cover - filesystem failure - return [] - - return sorted(facets_with_todos) - - -def get_todo_days_in_range(facet: str, day_from: str, day_to: str) -> list[str]: - """Return a sorted list of days with todos in the given range for a facet. - - Args: - facet: Facet name (e.g., "personal", "work"). - day_from: Start day in ``YYYYMMDD`` format (inclusive). - day_to: End day in ``YYYYMMDD`` format (inclusive). - - Returns: - Sorted list of day strings (YYYYMMDD) that have todo files within the range. - Returns empty list if no todos exist or journal path is invalid. - """ - todos_dir = Path(get_journal()) / "facets" / facet / "todos" - - if not todos_dir.is_dir(): - return [] - - days: list[str] = [] - - try: - for f in todos_dir.iterdir(): - if not f.is_file() or f.suffix != ".jsonl": - continue - stem = f.stem - if len(stem) != 8 or not stem.isdigit(): - continue - if day_from <= stem <= day_to: - days.append(stem) - except OSError: # pragma: no cover - filesystem failure - return [] - - return sorted(days) - - -def find_cross_facet_matches( - text: str, - day: str, - exclude_facet: str, - *, - threshold: float = 70.0, -) -> list[dict]: - """Find fuzzy matches for ``text`` across other facets within a ยฑ1 day window. - - Args: - text: The candidate todo text to check for duplicates. - day: Target day in ``YYYYMMDD`` format. Scanning covers day-1, day, day+1. - exclude_facet: Facet to skip (the facet being added to). - threshold: Minimum ``token_sort_ratio`` score to consider a match (0โ€“100). - - Returns: - List of match dicts sorted by score descending. - Each dict: ``{"score": float, "facet": str, "day": str, "text": str, "line": int}``. - """ - from rapidfuzz import fuzz - - target = datetime.strptime(day, "%Y%m%d") - days = [(target + timedelta(days=d)).strftime("%Y%m%d") for d in (-1, 0, 1)] - - facets_dir = Path(get_journal()) / "facets" - if not facets_dir.is_dir(): - return [] - - matches: list[dict] = [] - try: - for facet_dir in facets_dir.iterdir(): - if not facet_dir.is_dir(): - continue - facet_name = facet_dir.name - if facet_name == exclude_facet: - continue - - for candidate_day in days: - todo_path = facet_dir / "todos" / f"{candidate_day}.jsonl" - if not todo_path.is_file(): - continue - checklist = TodoChecklist.load(candidate_day, facet_name) - for item in checklist.items: - if item.cancelled or item.completed: - continue - score = fuzz.token_sort_ratio(text, item.text) - if score >= threshold: - matches.append( - { - "score": score, - "facet": facet_name, - "day": candidate_day, - "text": item.text, - "line": item.index, - } - ) - except OSError: # pragma: no cover - filesystem failure - return [] - - matches.sort(key=lambda match: match["score"], reverse=True) - return matches - - -def format_todos( - entries: list[dict], - context: dict | None = None, -) -> tuple[list[dict], dict]: - """Format todo JSONL entries to markdown chunks. - - This is the formatter function used by the formatters registry. - - Args: - entries: Raw JSONL entries (one todo item per line) - context: Optional context with: - - file_path: Path to JSONL file (for extracting facet name and day) - - Returns: - Tuple of (chunks, meta) where: - - chunks: List of dicts with keys: - - timestamp: int (unix ms) - - markdown: str - - source: dict (original todo entry) - - meta: Dict with optional "header" and "error" keys - """ - ctx = context or {} - file_path = ctx.get("file_path") - meta: dict[str, Any] = {} - chunks: list[dict[str, Any]] = [] - skipped_count = 0 - - # Extract facet name and day from path - facet_name = "unknown" - day_str: str | None = None - file_mtime_ms = 0 - - if file_path: - file_path = Path(file_path) - - # Get file modification time as fallback timestamp (in milliseconds) - try: - file_mtime_ms = int(file_path.stat().st_mtime * 1000) - except (OSError, ValueError): - pass - - # Extract facet name from path - # Pattern: facets/{facet}/todos/{day}.jsonl - path_str = str(file_path) - facet_match = re.search(r"facets/([^/]+)/todos", path_str) - if facet_match: - facet_name = facet_match.group(1) - - # Extract day from filename - if file_path.stem.isdigit() and len(file_path.stem) == 8: - day_str = file_path.stem - - # Build header - if day_str: - formatted_day = f"{day_str[:4]}-{day_str[4:6]}-{day_str[6:8]}" - header_title = f"# Todos: {facet_name} ({formatted_day})" - else: - header_title = f"# Todos: {facet_name}" - - item_count = len(entries) - meta["header"] = f"{header_title}\n\n{item_count} items" - - # Format each todo item as a chunk - for i, entry in enumerate(entries): - # Skip entries without text field - if "text" not in entry: - skipped_count += 1 - continue - - # Create TodoItem using existing from_jsonl - item = TodoItem.from_jsonl(entry, i + 1, day=day_str) - - # Determine timestamp: updated_at -> created_at -> file mtime - ts = item.updated_at or item.created_at or file_mtime_ms - - # Format as list item using existing display_line - markdown = f"* {item.display_line()}" - - chunks.append( - { - "timestamp": ts, - "markdown": markdown, - "source": entry, - } - ) - - # Report skipped entries - if skipped_count > 0: - error_msg = f"Skipped {skipped_count} entries missing 'text' field" - if file_path: - error_msg += f" in {file_path}" - meta["error"] = error_msg - logging.info(error_msg) - - # Indexer metadata - agent is always "todo" for todo items - meta["indexer"] = {"agent": "todo"} - - return chunks, meta diff --git a/solstone/apps/todos/workspace.html b/solstone/apps/todos/workspace.html deleted file mode 100644 index 456a18ec4..000000000 --- a/solstone/apps/todos/workspace.html +++ /dev/null @@ -1,1597 +0,0 @@ - - -
    -
    - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
    - {% for category, message in messages %} -
    - {{ message }} - -
    - {% endfor %} -
    - {% endif %} - {% endwith %} - - {% if todos_by_facet %} - {% for facet_name in todos_by_facet %} - {% set facet_info = facet_map.get(facet_name, {}) %} - {% set facet_title = facet_info.get('title', facet_name.title()) %} - {% set facet_color = facet_info.get('color', '#6b7280') %} - {% set facet_emoji = facet_info.get('emoji', '') %} - {% set totals = facet_totals[facet_name] %} - {% set incomplete_count = totals.incomplete_total %} - {% set completed_count = totals.completed_total %} - {% set total_count = incomplete_count + completed_count %} - {% set visible_incomplete = visible_incomplete_by_facet[facet_name] %} - {% set visible_completed = visible_completed_by_facet[facet_name] %} - {% if total_count == 0 %} - {% set count_emoji = '0๏ธโƒฃ' %} - {% elif incomplete_count == 0 %} - {% set count_emoji = 'โœ…' %} - {% elif incomplete_count < 10 %} - {% set count_emoji = ['0๏ธโƒฃ', '1๏ธโƒฃ', '2๏ธโƒฃ', '3๏ธโƒฃ', '4๏ธโƒฃ', '5๏ธโƒฃ', '6๏ธโƒฃ', '7๏ธโƒฃ', '8๏ธโƒฃ', '9๏ธโƒฃ'][incomplete_count] %} - {% else %} - {% set count_emoji = '*๏ธโƒฃ' %} - {% endif %} - -
    -
    - - {% if facet_emoji %}{% endif %} -

    {{ facet_title }}

    - {% if total_count == 0 %}{% elif incomplete_count == 0 %}all done{% else %}{{ completed_count }} of {{ total_count }} done{% endif %} - - {{ count_emoji }} -
    - -
      - {% for item in visible_incomplete %} - {% include "todos/_row.html" %} - {% endfor %} - {% if totals.incomplete_hidden > 0 %} -
    • - -
    • - {% endif %} - {% for item in visible_completed %} - {% include "todos/_row.html" %} - {% endfor %} - {% if totals.completed_hidden > 0 %} -
    • - -
    • - {% endif %} -
    -
    - - - -
    - {% if total_count == 0 %} -
    No todos in {{ facet_title }}
    - {% endif %} -
    - {% endfor %} - {% else %} -
    -
    - - - - -
    -
    No todos for today
    -
    Add a todo below, or use #facet to target a specific list
    -
    - - - -
    -
    - {% endif %} -
    - - diff --git a/solstone/apps/utils.py b/solstone/apps/utils.py index 31cda6ec4..30591b521 100644 --- a/solstone/apps/utils.py +++ b/solstone/apps/utils.py @@ -122,9 +122,9 @@ def log_app_action( actions (settings changes, observer management, etc.). Args: - app: App name where action originated (e.g., "entities", "todos") + app: App name where action originated (e.g., "entities", "activities") facet: Facet where action occurred, or None for journal-level actions - action: Action type (e.g., "entity_add", "todo_complete") + action: Action type (e.g., "entity_attach", "activity_update") params: Action-specific parameters to record day: Day in YYYYMMDD format (defaults to today) diff --git a/solstone/convey/config.py b/solstone/convey/config.py index 9f3c73c73..b5eb53c5e 100644 --- a/solstone/convey/config.py +++ b/solstone/convey/config.py @@ -33,7 +33,6 @@ DEFAULT_RAIL_APPS = [ "home", "sol", "chat", - "todos", "activities", "transcripts", "observer", @@ -437,7 +436,7 @@ def update_facet_order() -> tuple[Any, int]: def update_app_order() -> tuple[Any, int]: """POST /api/config/apps/order - Update app ordering. - Request body: {"order": ["home", "activities", "todos"]} + Request body: {"order": ["home", "activities", "entities"]} Returns: JSON success/error response diff --git a/solstone/convey/reasons.py b/solstone/convey/reasons.py index 90f732ffd..b1fbfc5ad 100644 --- a/solstone/convey/reasons.py +++ b/solstone/convey/reasons.py @@ -164,7 +164,7 @@ ENTITY_OPERATION_FAILED = Reason( 500, ) -# facets/activities/todos +# facets/activities FACET_NOT_FOUND = Reason("facet_not_found", "I couldn't find that facet.", 404) FACET_ALREADY_EXISTS = Reason( "facet_already_exists", @@ -191,17 +191,6 @@ ACTIVITY_PROTECTED = Reason( "I can't remove that always-on activity.", 400, ) -TODO_OPERATION_FAILED = Reason( - "todo_operation_failed", - "I couldn't update that todo.", - 500, -) -TODO_BUSY = Reason( - "todo_busy", - "I couldn't update that todo right now because it was busy. Try again in a moment.", - 503, -) - # agent/talent AGENT_UNAVAILABLE = Reason( "agent_unavailable", diff --git a/solstone/talent/digest.md b/solstone/talent/digest.md index 80dbd0cfa..32b87b013 100644 --- a/solstone/talent/digest.md +++ b/solstone/talent/digest.md @@ -21,11 +21,10 @@ First compute the date seven days ago in `YYYYMMDD` format. Then gather state wi `sol call journal facets` `sol call entities search --since --limit 12` `journal routines list` -`sol call todos list` `read_file` `identity/self.md` `read_file` `identity/partner.md` -Then write a 400-600 word digest in plain English. Use second person throughout. No bullets, no headings, no numbered lists, no markdown structure. Cover who you are, what is happening now, the active work and agenda, the next 48 hours, the key people in motion this week, open loops, and routine state. If one source is thin or unavailable, work with what you have instead of stalling. +Then write a 400-600 word digest in plain English. Use second person throughout. No bullets, no headings, no numbered lists, no markdown structure. Cover who you are, what is happening now, the active work and agenda, the next 48 hours, the key people in motion this week, open loops from the available journal sources, and routine state. If one source is thin or unavailable, work with what you have instead of stalling. Finalize by writing the digest exactly once. `journal identity digest --write` is the owned write command for `digest.md` (there is no `sol call` verb for it diff --git a/solstone/talent/exec.md b/solstone/talent/exec.md index 3ffa7e5f7..49e3c82fb 100644 --- a/solstone/talent/exec.md +++ b/solstone/talent/exec.md @@ -19,10 +19,9 @@ $routine_suggestion Match your response depth to the question. The owner doesn't pick a mode โ€” you decide. **One-liner responses** for quick actions: -- Adding, completing, or canceling todos - Creating, updating, or canceling calendar events - Navigating to an app or facet -- Simple lookups (list today's events, show upcoming todos) +- Simple lookups (list today's events, show recent entity context) - Confirming an action you just completed - Pausing, resuming, or deleting a routine @@ -98,7 +97,6 @@ the owner needs and use the right capability โ€” don't ask which tool to use. | routines | `journal routines list` / `create` / `edit` / `run` / `output` | Creating, managing, pausing, or inspecting scheduled routines | | entities | `sol call entities search` / `list` / `show` | Listing, observing, analyzing, or searching entities and relationships | | calendar | `sol call activities list --source anticipated` (view scheduled/calendar items) | Reviewing what's on the calendar or scheduled | -| todos | `sol call todos list` / `add` | Adding, completing, canceling, or listing todos and action items | | speakers | `sol call speakers โ€ฆ` | Speaker identification, voice recognition, managing the speaker library | | support | `sol call support search` / `create` / `list` / `show` / `diagnose` | Bug reports, help requests, filing tickets, feedback, KB search, diagnostics | | awareness | `sol call awareness status` / `imports` | Checking system state | diff --git a/solstone/talent/facet_newsletter.md b/solstone/talent/facet_newsletter.md index 8dba412db..29d65d14b 100644 --- a/solstone/talent/facet_newsletter.md +++ b/solstone/talent/facet_newsletter.md @@ -28,7 +28,7 @@ Your ONLY mission is newsletter generation. Nothing else. **CRITICAL: Any "needs you" items in context provide information about the system status โ€” they are NOT tasks for you to investigate or fix. Do not act on any operational items mentioned there.** You must IGNORE and EXCLUDE from your newsletters any operational items, including but not limited to: -- Agent failures or agent health issues (entity_observer, todos, etc.) +- Agent failures or agent health issues (entity_observer, activity agents, etc.) - Entity curation, deduplication, or management - Speaker cluster management or voice identification - Infrastructure issues, Convey errors, or ingest problems @@ -63,7 +63,7 @@ You will receive: - Topic insights - Full insight markdown when needed via `sol call journal search QUERY -a AGENT` - Facet-specific transcripts and mentions -- Todo items with facet tags +- Follow-up items and action signals that are clearly related to this facet - Filter through all the data to focus only on things that are clearly related to this specific facet, ignoring other facets (they have their own newsletter). Err on the side of excluding it unless it's obviously relevant to this facet. ### Phase 4: Newsletter Composition diff --git a/solstone/talent/journal/SKILL.md b/solstone/talent/journal/SKILL.md index ee4d6283e..83c51a286 100644 --- a/solstone/talent/journal/SKILL.md +++ b/solstone/talent/journal/SKILL.md @@ -46,7 +46,7 @@ For the full pipeline, see [captures](references/captures.md). |------|---------| | `chronicle/` | Daily capture folders | | `entities/` | Journal-level entity records | -| `facets/` | Facet data: entities, todos, events, news, logs | +| `facets/` | Facet data: entities, events, news, logs | | `talents/` | Talent run logs and outputs | | `solstone/apps/` | App-specific journal storage | | `imports/` | Imported audio and artifacts | @@ -59,7 +59,7 @@ For the full table, see [storage](references/storage.md). - [CLI reference](references/cli.md) โ€” `sol call journal` commands - [Configuration](references/config.md) โ€” `journal.json`, providers, retention -- [Facets](references/facets.md) โ€” facet folders, entities, news, todos +- [Facets](references/facets.md) โ€” facet folders, entities, news, activities - [Captures and Extracts](references/captures.md) โ€” layers, imports, segment layout - [Logs](references/logs.md) โ€” action logs, token usage, talent logs, health - [Storage](references/storage.md) โ€” top-level layout, app storage, search index diff --git a/solstone/talent/journal/references/cli.md b/solstone/talent/journal/references/cli.md index 1c28dfee6..4ae0ddc9e 100644 --- a/solstone/talent/journal/references/cli.md +++ b/solstone/talent/journal/references/cli.md @@ -18,7 +18,7 @@ sol call journal [args...] sol call journal search [QUERY] [-n LIMIT] [--offset N] [-d DAY] [--day-from DAY] [--day-to DAY] [-f FACET] [-a AGENT] ``` -Search the journal index across insights, transcripts, historical event extracts, activity records, entities, and todos. +Search the journal index across insights, transcripts, historical event extracts, activity records, and entities. - `QUERY`: optional text query. Defaults to empty string (`""`), which works as browse mode when filters are provided. - `-n, --limit`: max results (default `10`). @@ -179,7 +179,7 @@ sol call journal facet merge SOURCE --into DEST [--consent] - `--into DEST` โ€” destination facet to receive all data - `--consent` โ€” required for agent audit trail when called by an agent -Moves all entities, open todos (not completed or cancelled), non-cancelled calendar events, and news files from SOURCE into DEST. For entity conflicts (entity exists in both): DEST relationship wins, observations are appended without duplicates. For news conflicts (same date file in both): DEST file is preserved, SOURCE file is skipped. Completed/cancelled todos and cancelled events are not moved (they are deleted with the source facet). After all data is moved, SOURCE is permanently deleted and the index is rebuilt. +Moves all entities, non-cancelled calendar events, and news files from SOURCE into DEST. For entity conflicts (entity exists in both): DEST relationship wins, observations are appended without duplicates. For news conflicts (same date file in both): DEST file is preserved, SOURCE file is skipped. Cancelled events are not moved. After all data is moved, SOURCE is permanently deleted and the index is rebuilt. Prints a summary of what will be moved before performing any mutations. diff --git a/solstone/talent/journal/references/config.md b/solstone/talent/journal/references/config.md index 4e895ee99..7c303a689 100644 --- a/solstone/talent/journal/references/config.md +++ b/solstone/talent/journal/references/config.md @@ -58,7 +58,7 @@ Fields: ```json { "facets": {"order": ["work", "personal"], "selected": "work"}, - "apps": {"order": ["home", "activities", "todos"], "starred": ["home", "todos"]} + "apps": {"order": ["home", "activities", "entities"], "starred": ["home", "entities"]} } ``` diff --git a/solstone/talent/journal/references/facets.md b/solstone/talent/journal/references/facets.md index f4496ada3..fc04158c7 100644 --- a/solstone/talent/journal/references/facets.md +++ b/solstone/talent/journal/references/facets.md @@ -9,7 +9,6 @@ Each facet is organized as `facets//` where `` is a descriptive sh - `facet.json` โ€“ metadata file with facet title and description. - `activities/` โ€“ configured activities and completed activity records (see [activity records](#activity-records)). - `entities/` โ€“ entity relationships and detected entities (see [facet entities](#facet-entities)). -- `todos/` โ€“ daily todo lists (see [facet-scoped todos](#facet-scoped-todos)). - `events/` โ€“ historical extracted events per day (see [historical event extracts](captures.md#historical-event-extracts)). - `news/` โ€“ daily news and updates relevant to the facet (optional). - `logs/` โ€“ action audit logs for tool calls (optional, see [action logs](logs.md#action-logs)). @@ -249,56 +248,3 @@ facets/work/activities/20260209/coding_095809_303/session_review.md ``` These output directories are only created when activity-scheduled agents run. The path is computed by `get_activity_output_path()` in `solstone/think/activities.py` and passed as `output_path` in the agent request. Output files are indexed for search via the `facets/*/activities/*/*/*.md` formatter pattern. - -## Facet-Scoped Todos - -Todos are organized by facet in `facets/{facet}/todos/{day}.jsonl` where each file stores todo items as JSON Lines. Todos belong to a specific facet (e.g., "personal", "work", "research") and are completely separated by scope. - -**File path pattern:** -``` -facets/personal/todos/20250110.jsonl -facets/work/todos/20250110.jsonl -facets/research/todos/20250112.jsonl -``` - -Each file contains one JSON object per line, with the line number (1-indexed) serving as the stable todo ID. - -```jsonl -{"text": "Draft standup update"} -{"text": "Review PR #1234 for indexing tweaks", "time": "14:30"} -{"text": "Morning planning session notes", "completed": true} -{"text": "Cancel meeting with vendor", "cancelled": true} -``` - -## Format Specification - -**JSONL structure:** - -Each line is a JSON object with the following fields: -- `text` (required) โ€“ Task description -- `time` (optional) โ€“ Scheduled time in `HH:MM` format (e.g., `"14:30"`) -- `completed` (optional) โ€“ Set to `true` when task is done -- `cancelled` (optional) โ€“ Set to `true` for soft-deleted tasks -- `created_at` (optional) โ€“ Unix timestamp in milliseconds when todo was created -- `updated_at` (optional) โ€“ Unix timestamp in milliseconds of last modification - -**Facet context:** -- Facet is determined by the file location, not inline tags -- Each facet has its own independent todo list for each day -- Work todos (`facets/work/todos/`) are completely separate from personal todos (`facets/personal/todos/`) - -**Rules:** -- Line number is the stable todo ID (1-indexed); todos are never removed, only cancelled -- Append new todos at the end of the file to maintain stable line numbering -- Mark completed items with `"completed": true` -- Cancel items with `"cancelled": true` (soft delete preserves line numbers) - -**Tool Access:** -All todo operations require both `day` and `facet` parameters: -- `todo_list(day, facet)` โ€“ view numbered checklist for a specific facet -- `todo_add(day, facet, text)` โ€“ append new todo -- `todo_done(day, facet, line_number)` โ€“ mark complete -- `todo_cancel(day, facet, line_number)` โ€“ cancel entry (soft delete) -- `todo_upcoming(limit, facet=None)` โ€“ view upcoming todos (optionally filtered by facet) - -This facet-scoped structure provides true separation of concerns while enabling automated tools to manage tasks deterministically. diff --git a/solstone/talent/journal/references/logs.md b/solstone/talent/journal/references/logs.md index e39f978ef..0ebf34f7c 100644 --- a/solstone/talent/journal/references/logs.md +++ b/solstone/talent/journal/references/logs.md @@ -5,7 +5,7 @@ Action logs record an audit trail of owner-initiated actions and agent tool calls. There are two types: - **Journal-level logs** (`config/actions/`) โ€“ actions not tied to a specific facet (settings changes, observer management) -- **Facet-scoped logs** (`facets/{facet}/logs/`) โ€“ actions within a specific facet (todos, entities) +- **Facet-scoped logs** (`facets/{facet}/logs/`) โ€“ actions within a specific facet (activities, entities) ### Journal Action Logs @@ -31,10 +31,12 @@ The `logs/` directory within each facet records facet-scoped actions. Logs are o { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos:todo", - "action": "todo_add", + "actor": "entities:entities", + "action": "entity_attach", "params": { - "text": "Review project proposal" + "type": "Person", + "name": "Alice Johnson", + "description": "Lead engineer on the API project" }, "facet": "work", "use_id": "1765870373972" @@ -48,7 +50,7 @@ Both log types share the same structure: - `timestamp` โ€“ ISO 8601 timestamp of the action - `source` โ€“ Origin type: "app" for web UI, "tool" for agent tools - `actor` โ€“ App or tool name that performed the action -- `action` โ€“ Action name (e.g., "todo_add", "identity_update") +- `action` โ€“ Action name (e.g., "entity_attach", "identity_update") - `params` โ€“ Action-specific parameters - `facet` โ€“ Facet name (only present in facet-scoped logs) - `use_id` โ€“ Agent ID (only present for agent tool actions) diff --git a/solstone/talent/journal/references/storage.md b/solstone/talent/journal/references/storage.md index 2c34b49b4..f9bfd5a6f 100644 --- a/solstone/talent/journal/references/storage.md +++ b/solstone/talent/journal/references/storage.md @@ -8,7 +8,7 @@ This document describes the layout of a **journal** directory where all captures |----------------|---------| | `chronicle/` | Container for daily capture folders (`YYYYMMDD/`) containing segments, extracts, and agent outputs | | `entities/` | Journal-level entity identity records (`/entity.json`) | -| `facets/` | Facet-specific data: activities, entity relationships, todos, historical events, news, action logs | +| `facets/` | Facet-specific data: activities, entity relationships, historical events, news, action logs | | `talents/` | Talent run logs in per-talent subdirectories (`/.jsonl`), day indexes (`.jsonl`), and latest-run symlinks (`.log`) | | `solstone/apps/` | App-specific storage (distinct from codebase `solstone/apps/`) | | `streams/` | Per-stream state files (`.json`) tracking segment chains and sequence numbers | @@ -38,7 +38,7 @@ Apps typically use `config.json` for journal-specific settings and create subdir The `indexer/` directory contains the full-text search index built from journal content. **Files:** -- `indexer/journal.sqlite` โ€“ FTS5 SQLite database containing indexed chunks from agent outputs, activities, historical events, entities, todos, and action logs +- `indexer/journal.sqlite` โ€“ FTS5 SQLite database containing indexed chunks from agent outputs, activities, historical events, entities, and action logs The indexer converts content to markdown chunks via the formatters framework, then indexes with metadata fields (day, facet, agent) for filtering. Raw audio/screen transcripts are formattable but not indexed โ€” agent outputs provide more useful search results. Use `get_journal_index()` from `solstone/think/indexer/journal.py` to access the database programmatically. diff --git a/solstone/talent/morning_briefing.md b/solstone/talent/morning_briefing.md index d33940f8b..02c55221f 100644 --- a/solstone/talent/morning_briefing.md +++ b/solstone/talent/morning_briefing.md @@ -13,7 +13,7 @@ $facets -You are generating the morning briefing for $agent_name โ€” a structured daily digest that synthesizes all agent outputs, calendar, and todos into an actionable start-of-day view. +You are generating the morning briefing for $agent_name โ€” a structured daily digest that synthesizes agent outputs, calendar, follow-ups, and current context into an actionable start-of-day view. This is not a conversation. Gather data, synthesize, then call `emit_final(content=)`. The system saves the `content` argument automatically. @@ -24,16 +24,15 @@ Call all sources upfront. Some may return empty โ€” that's expected, especially 1. `sol call journal facets` โ€” list active facets 2. For each facet: `sol call journal news FACET --day $day_YYYYMMDD` โ€” facet newsletter 3. `sol call activities list --source anticipated --day $day_YYYYMMDD` โ€” today's scheduled items with participants -4. `sol call todos list` โ€” pending action items across all facets -5. `read_file` `identity/pulse.md` โ€” current pulse narrative and needs-you items -6. `read_file` `identity/partner.md` โ€” owner behavioral profile (informs tone and emphasis) -7. `sol call journal search "" -d $day_YYYYMMDD -a followups -n 10` โ€” follow-up items from today -8. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+7>` โ€” forward-looking scheduled items -9. `sol call journal search "" -d $day_YYYYMMDD -a decisions -n 10` โ€” yesterday's consequential decisions -10. For each of the next 7 days after today: `sol call activities list --source anticipated --day YYYYMMDD` โ€” upcoming scheduled items for forward look +4. `read_file` `identity/pulse.md` โ€” current pulse narrative and needs-you items +5. `read_file` `identity/partner.md` โ€” owner behavioral profile (informs tone and emphasis) +6. `sol call journal search "" -d $day_YYYYMMDD -a followups -n 10` โ€” follow-up items from today +7. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+7>` โ€” forward-looking scheduled items +8. `sol call journal search "" -d $day_YYYYMMDD -a decisions -n 10` โ€” yesterday's consequential decisions +9. For each of the next 7 days after today: `sol call activities list --source anticipated --day YYYYMMDD` โ€” upcoming scheduled items for forward look Also run: -11. `read_file` `identity/health.md` โ€” sol's federated health surface (synthesized by the steward talent) +10. `read_file` `identity/health.md` โ€” sol's federated health surface (synthesized by the steward talent) ## Phase 1.5: Pre-pass audit @@ -43,8 +42,7 @@ Before synthesizing, audit what you gathered. This step uses only the data from - `segments` โ€” total transcript segments across all journal search calls - `anticipated_activities` โ€” anticipated activities for today (step 3) - `facet_newsletters` โ€” facets that returned a newsletter (step 2) - - `followups` โ€” follow-up items returned (step 7) - - `todos` โ€” pending todo items (step 4) + - `followups` โ€” follow-up items returned (step 6) - `steward_health` โ€” whether the steward health surface returned parseable content and how many Needs your attention bullets it surfaced 2. **Identify gaps.** Record a gap for each source that returned zero results or is otherwise missing. A gap is not an error โ€” it means the briefing has a blind spot in that area. Examples: `"no facet newsletters available"`, `"no follow-up items found"`, `"no anticipated activities today"`. @@ -71,7 +69,7 @@ Build five sections from the gathered data. **Omit any section entirely if it ha - **Search results:** The header includes an `id` (e.g. `20260304/archon/143022_300/talents/followups.md:2`). Strip `:idx`, then strip `/talents/{agent}.md` โ†’ `sol://20260304/archon/143022_300`. - **Facet newsletters:** `sol://facets/{facet}/news/{day_YYYYMMDD}`. -**Your Day** โ€” What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context from the gathered data when available. Include relevant todos due today. If no anticipated activities exist, lead with the highest-priority todos. +**Your Day** โ€” What's ahead today. Lead with anticipated activities in chronological order. For each meeting, include who's attending and source-backed context from the gathered data when available. If no anticipated activities exist, lead with the highest-priority follow-ups or pulse needs. **Yesterday** โ€” What happened. Draw from facet newsletters, pulse, and decisions agent output. Highlight accomplishments, consequential decisions, and notable interactions. Keep to 3-5 bullets max. Only include if facet newsletters or decisions have content for the analysis day. Attribute each highlight to its source: `([facet newsletter](sol://facets/{facet}/news/{day}))`. @@ -79,13 +77,13 @@ Grade highlights by evidence strength. **High** (corroborated by multiple source **Needs Attention** โ€” Ranked action list. Synthesize from all sources into a single prioritized list: 0. Pipeline gaps from yesterday's processing - 1. Overdue commitments (todos past due, missed follow-ups) + 1. Overdue commitments and missed follow-ups 2. Pending follow-ups (items flagged by the followups agent) - 3. Unscheduled todos (action items with no calendar time blocked) + 3. Important pulse needs without calendar time blocked Do NOT include pipeline gaps when the steward health surface has no Needs your attention bullets. Zero noise on normal days. Attribute commitments and follow-ups to the originating segment: `(committed [date](sol://...))`, `(flagged [date](sol://...))`. For inferred items: `(inferred from [source](sol://...))`. -Grade action items by evidence strength. **High** (explicit commitment with date, or overdue todo): state assertively โ€” "Follow up on Series A term sheet โ€” committed March 20, now overdue." **Medium** (flagged by followups agent with moderate confidence, or clear single-source item): present with attribution โ€” "Review CI pipeline logs (flagged yesterday)." **Low** (inferred obligation from ambiguous mention, or low-confidence followup): hedge โ€” "Possible commitment to send deck to investors" or "May need to follow up on the API discussion." When upstream followup output includes a `Confidence:` score, use it: 0.85+ high, 0.50โ€“0.84 medium, below 0.50 low. Never hedge explicit commitments with clear dates; never present inferred obligations as definite action items. +Grade action items by evidence strength. **High** (explicit commitment with date, or overdue follow-up): state assertively โ€” "Follow up on Series A term sheet โ€” committed March 20, now overdue." **Medium** (flagged by followups agent with moderate confidence, or clear single-source item): present with attribution โ€” "Review CI pipeline logs (flagged yesterday)." **Low** (inferred obligation from ambiguous mention, or low-confidence followup): hedge โ€” "Possible commitment to send deck to investors" or "May need to follow up on the API discussion." When upstream followup output includes a `Confidence:` score, use it: 0.85+ high, 0.50โ€“0.84 medium, below 0.50 low. Never hedge explicit commitments with clear dates; never present inferred obligations as definite action items. **Forward Look** โ€” What's coming. Draw from anticipated activity records and upcoming scheduled items (next 7 days). Note preparation needed for upcoming meetings or deadlines. Attribute schedule-derived items: `(from [schedule](sol://...))`. Data source: `sol call activities list --source anticipated` or the schedule talent output path. @@ -108,12 +106,11 @@ sources: anticipated_activities: [count] facet_newsletters: [count] followups: [count] - todos: [count] steward_health: [present|missing] gaps: [list of gap descriptions, or empty list [] if none] --- -> [coverage preamble โ€” 1-2 sentences summarizing source counts and gaps. Example: "Built from 12 transcript segments, 4 anticipated activities, 2 facet newsletters, 5 follow-ups, 8 todos. No gaps." or with gaps: "Built from 8 segments, 2 activities. Gaps: no facet newsletters today."] +> [coverage preamble โ€” 1-2 sentences summarizing source counts and gaps. Example: "Built from 12 transcript segments, 4 anticipated activities, 2 facet newsletters, and 5 follow-ups. No gaps." or with gaps: "Built from 8 segments, 2 activities. Gaps: no facet newsletters today."] ## Your Day - **09:00** โ€” Sync with Sarah Chen on Q2 roadmap. Last discussed launch timeline (from your [March standup](sol://20260313/archon/091500_300)). @@ -146,4 +143,4 @@ Call `emit_final(content=)`. The `content` argument IS the br - Lead each section with the most important item. - Use bullets, not paragraphs. - Don't include greetings, sign-offs, or meta-commentary about being an AI. -- On a quiet day with minimal data, produce only the sections that have content. A briefing with just "Your Day" listing a few todos is perfectly valid. +- On a quiet day with minimal data, produce only the sections that have content. A briefing with just "Your Day" listing a few scheduled items or follow-ups is perfectly valid. diff --git a/solstone/talent/partner.md b/solstone/talent/partner.md index 85af897dc..1215619ac 100644 --- a/solstone/talent/partner.md +++ b/solstone/talent/partner.md @@ -31,7 +31,6 @@ and query each source. If a source returns empty or errors, skip it โ€” gaps are 1. For each of the past 7 days: - `sol call activities list --source anticipated --day YYYYMMDD` โ€” scheduled activity patterns - - `sol call todos list YYYYMMDD` โ€” task patterns 2. For each active facet (from `sol call journal facets`): - `sol call journal news FACET --day YYYYMMDD` (most recent day available) โ€” work themes 3. `sol call journal search "" --day-from YYYYMMDD -a pulse -n 10` โ€” pulse narratives for behavioral patterns @@ -48,12 +47,12 @@ for each section you update โ€” it is the owned write command for `partner.md` **work patterns** โ€” When do they work? How do they structure their day? Do they batch meetings or spread them out? Do they context-switch frequently or deep-focus? -What times are they most active? Evidence: calendar density, todo completion timing, -segment activity patterns. +What times are they most active? Evidence: calendar density, segment activity patterns, +and pulse/news timing. **communication style** โ€” How do they express themselves? Brief or detailed? Do they -prefer async (todos, notes) or sync (meetings, calls)? How do they frame requests -vs decisions? Evidence: meeting frequency, todo phrasing patterns, entity interaction +prefer async written work or sync meetings and calls? How do they frame requests +vs decisions? Evidence: meeting frequency, journal phrasing patterns, entity interaction frequency. **relationship priorities** โ€” Who matters most to them right now? Which relationships @@ -110,7 +109,7 @@ For each section with new observations, write it: ```bash journal identity partner --update-section 'work patterns' --value 'My partner tends to batch meetings before noon and protects afternoon blocks for focused work. Calendar data from March 25-31 shows 85% of meetings before 12:00 (sol://20260328/archon/091500_300). -Deep work sessions typically run 2-3 hours โ€” todo completion spikes correlate with these blocks.' +Deep work sessions typically run 2-3 hours โ€” calendar and activity signals show fewer interruptions during these blocks.' ``` Only update sections where you have meaningful new evidence. Leave `[observing]` diff --git a/solstone/talent/patterns/provenance.md b/solstone/talent/patterns/provenance.md index 9a624e63a..3ce17d347 100644 --- a/solstone/talent/patterns/provenance.md +++ b/solstone/talent/patterns/provenance.md @@ -11,12 +11,12 @@ Canonical implementation: `solstone/talent/morning_briefing.md`. A structured preamble summarizing what data sources were consulted and what gaps exist. Appears at the top of the output so the reader knows the briefing's evidence base before reading any claims. **What it includes:** -- Source counts (segments, calendar events, entities consulted, newsletters, followups, todos) +- Source counts (segments, calendar events, entities consulted, newsletters, followups) - Gaps โ€” sources that returned zero results or errored - YAML frontmatter with machine-readable source counts **Example:** -> Built from 12 transcript segments, 4 calendar events, 3 entity profiles, 2 facet newsletters, 5 follow-ups, 8 todos. Gaps: entity intelligence unavailable for Sarah Chen; no facet newsletters today. +> Built from 12 transcript segments, 4 calendar events, 3 entity profiles, 2 facet newsletters, and 5 follow-ups. Gaps: entity intelligence unavailable for Sarah Chen; no facet newsletters today. ### 2. Source Attribution diff --git a/solstone/talent/pulse.md b/solstone/talent/pulse.md index 2f237663e..c4f5f90c3 100644 --- a/solstone/talent/pulse.md +++ b/solstone/talent/pulse.md @@ -26,8 +26,7 @@ Read current state. Use the `read_file` tool for the identity files (relative to 2. `read_file` `identity/self.md` โ€” who the owner is 3. `read_file` `identity/partner.md` โ€” behavioral profile of the owner 4. `read_file` `identity/awareness.md` โ€” current situational awareness (calendar, routines, activity, entities) -5. `sol call todos list` โ€” pending action items -6. `sol call entities search` โ€” recent entity activity +5. `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. @@ -55,8 +54,8 @@ action items the owner should notice. Format as markdown bullet points: - Third item ```` -Draw needs-you items from: pending todos, upcoming calendar events needing prep, -entity follow-ups, and anything the narrative highlights as important. +Draw needs-you items from: upcoming calendar events needing prep, entity follow-ups, +and anything the narrative highlights as important. ## Write output diff --git a/solstone/talent/reflection.md b/solstone/talent/reflection.md index ad1641bfb..48f217fc2 100644 --- a/solstone/talent/reflection.md +++ b/solstone/talent/reflection.md @@ -59,7 +59,6 @@ the owner needs and use the right capability โ€” don't ask which tool to use. | routines | `journal routines list` / `output` | Inspecting scheduled routines when cadence or habits matter | | entities | `sol call entities search` / `list` / `show` | Understanding people, projects, and relationships over time | | calendar | `sol call activities list --source anticipated` | Checking context around meetings or commitments when reflection depends on them | -| todos | `sol call todos list` | Reviewing commitments, open loops, and follow-ups | | support | `sol call support search` / `diagnose` | Bugs, feedback, KB search, and diagnostics when the task is operational rather than reflective | | awareness | `sol call awareness status` | Checking system state | diff --git a/solstone/talent/routines/SKILL.md b/solstone/talent/routines/SKILL.md index 6b17cc933..2eb497196 100644 --- a/solstone/talent/routines/SKILL.md +++ b/solstone/talent/routines/SKILL.md @@ -31,7 +31,7 @@ Meeting-prep is event-triggered, not clock-scheduled. Explain this naturally: "I 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 todos on Friday" / "I always lose track of follow-ups" +- **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 diff --git a/solstone/talent/routines/templates/commitment-audit.md b/solstone/talent/routines/templates/commitment-audit.md index deebd0a12..0379da7b2 100644 --- a/solstone/talent/routines/templates/commitment-audit.md +++ b/solstone/talent/routines/templates/commitment-audit.md @@ -1,6 +1,6 @@ { "name": "commitment-audit", - "description": "Audit open follow-ups, pending todos, and likely dropped commitments across facets.", + "description": "Audit open follow-ups and likely dropped commitments across facets.", "default_cadence": "0 10 * * 1", "default_timezone": "UTC", "default_facets": [] @@ -12,15 +12,14 @@ The goal is to surface what is overdue, stale, ambiguous, or at risk of being fo ## Gather -1. Use `sol call todos list` to review current pending action items. -2. Use `sol call journal search "" -a followups -n 20` to find follow-up items from recent journal activity. -3. Use `sol call journal facets` if you need to map commitments back to facets. -4. Use `sol call journal news FACET --day $day_YYYYMMDD` when a facet summary helps explain why something is still open. -5. Use `journal identity pulse` to compare explicit commitments with current focus and needs-you items. +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 todos from implied commitments found in follow-up output. +- 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. diff --git a/solstone/talent/routines/templates/energy-audit.md b/solstone/talent/routines/templates/energy-audit.md index 983f7235c..e55a75d43 100644 --- a/solstone/talent/routines/templates/energy-audit.md +++ b/solstone/talent/routines/templates/energy-audit.md @@ -12,9 +12,8 @@ You are preparing a weekly energy audit โ€” a reflection on where the owner's ti 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 `sol call todos list` to compare intended work against actual activity. -4. Use `journal identity pulse` for the current state narrative. -5. Use `sol call journal news FACET --day YYYYMMDD` for representative days across active facets. +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 diff --git a/solstone/talent/routines/templates/meeting-prep.md b/solstone/talent/routines/templates/meeting-prep.md index 4cc7458ee..f16907dbe 100644 --- a/solstone/talent/routines/templates/meeting-prep.md +++ b/solstone/talent/routines/templates/meeting-prep.md @@ -17,8 +17,7 @@ The routine prompt already includes an `Upcoming Activity` section with the titl 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 `sol call todos list` only if pending action items are directly relevant to the meeting. -7. Use `journal identity pulse` if it helps connect the meeting to current priorities or tensions. +6. Use `journal identity pulse` if it helps connect the meeting to current priorities or tensions. ## Synthesize diff --git a/solstone/talent/routines/templates/morning-briefing.md b/solstone/talent/routines/templates/morning-briefing.md index 76c463c00..3b40df24d 100644 --- a/solstone/talent/routines/templates/morning-briefing.md +++ b/solstone/talent/routines/templates/morning-briefing.md @@ -1,6 +1,6 @@ { "name": "morning-briefing", - "description": "Daily morning digest of calendar, todos, follow-ups, and relationship context.", + "description": "Daily morning digest of calendar, follow-ups, priorities, and relationship context.", "default_cadence": "0 7 * * *", "default_timezone": "UTC", "default_facets": [] @@ -14,17 +14,16 @@ This is not a conversation. Gather the information, synthesize it, and write a c 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 `sol call todos list` to see pending action items across facets. -4. Call `journal identity pulse` to capture current narrative, priorities, and needs-you items. -5. Call `sol call journal search "" -a followups -n 10` to find recent follow-up items. -6. For each person on today's calendar, call `sol call entities intelligence PERSON --brief`. -7. If a facet needs more detail, call `sol call journal news FACET --day $day_YYYYMMDD`. +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 todos that should shape the day. +- 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. diff --git a/solstone/talent/routines/templates/weekly-review.md b/solstone/talent/routines/templates/weekly-review.md index 28ee0d7ff..ab4375415 100644 --- a/solstone/talent/routines/templates/weekly-review.md +++ b/solstone/talent/routines/templates/weekly-review.md @@ -14,10 +14,9 @@ Gather evidence from the journal first, then synthesize a reflective but actiona 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 todos list` to review outstanding work and infer what likely got completed or deferred. -4. Use `sol call activities list --source anticipated --day YYYYMMDD` across the last 7 days to understand scheduled load and major time commitments. -5. Use `journal identity pulse` for the current state narrative. -6. Use `sol call journal news FACET --day YYYYMMDD` for any facet that needs a richer summary. +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 diff --git a/solstone/talent/solstone/SKILL.md b/solstone/talent/solstone/SKILL.md index c96856bb2..43eca713f 100644 --- a/solstone/talent/solstone/SKILL.md +++ b/solstone/talent/solstone/SKILL.md @@ -3,9 +3,9 @@ name: solstone version: 1.0.0 description: > Read-only query of the solstone journal from any project. Look up people - and relationships, today's events, todos; read transcripts. TRIGGER: + and relationships, today's events; read transcripts. TRIGGER: solstone, my journal, search my memory, what happened, who is, meeting - with, co-brain, recall, sol call journal/entities/transcripts/todos. + with, co-brain, recall, sol call journal/entities/transcripts. --- # solstone โ€” journal query interface @@ -56,9 +56,6 @@ sol call entities observations "" --facet "" Combine these commands to get a full picture of the current day: ```bash -# Upcoming todos -sol call todos upcoming - # Calendar events for today sol call activities list --source anticipated @@ -116,10 +113,10 @@ External callers should never need to set these. The commands above use explicit For richer answers, combine multiple commands: -**"Brief me on today"** โ€” events + todos + calendar: +**"Brief me on today"** โ€” events + active relationships: ```bash -sol call todos upcoming sol call activities list --source anticipated +sol call entities search --limit 5 ``` **"Prep me for a meeting with X"** โ€” recent transcript mentions: @@ -143,7 +140,6 @@ Most commands output plain text by default. Many support `--json` for structured This is a **read-only** interface. The journal is the person's private space. You cannot: - Create, delete, or modify facets -- Add or complete todos - Attach or modify entities - Write news or observations - Run pipeline operations (think, indexer, transcribe) diff --git a/solstone/talent/weekly_reflection.md b/solstone/talent/weekly_reflection.md index 0bbc97208..fcf37924d 100644 --- a/solstone/talent/weekly_reflection.md +++ b/solstone/talent/weekly_reflection.md @@ -48,15 +48,13 @@ Suggested sources: 4. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a decisions -n 12` 5. `sol call journal search "" --day-from $day_YYYYMMDD --day-to <+6> -a followups -n 12` 6. `sol call activities list --source anticipated --from $day_YYYYMMDD --to <+6>` -7. `sol call todos list` -8. Entity or relationship lookups only when they materially improve the reflection +7. Entity or relationship lookups only when they materially improve the reflection Before writing, audit your coverage: - `newsletters` - `activities` - `decisions` - `followups` -- `todos` - `relationship_signals` - `gaps` @@ -83,7 +81,6 @@ sources: activities: [count] decisions: [count] followups: [count] - todos: [count] relationship_signals: [count] gaps: [list of gap descriptions, or []] --- diff --git a/solstone/think/call.py b/solstone/think/call.py index 2acd4f5ac..d30c4c25e 100644 --- a/solstone/think/call.py +++ b/solstone/think/call.py @@ -31,7 +31,7 @@ def _discover_app_calls() -> None: Each ``call.py`` must export an ``app`` variable that is a ``typer.Typer`` instance. The app directory name becomes the - sub-command name (e.g. ``sol call todos list ...``). + sub-command name (e.g. ``sol call entities search ...``). Errors in one app do not prevent others from loading. """ diff --git a/solstone/think/cluster.py b/solstone/think/cluster.py index 0252857e9..ee76dc91c 100644 --- a/solstone/think/cluster.py +++ b/solstone/think/cluster.py @@ -32,10 +32,10 @@ def _filename_to_agent_key(filename: str) -> str: Reverse of get_output_name(): converts filesystem names back to agent keys. Args: - filename: Filename stem (e.g., "entities" or "_todos_review") + filename: Filename stem (e.g., "entities" or "_activities_review") Returns: - Agent key (e.g., "entities" or "todos:review") + Agent key (e.g., "entities" or "activities:review") """ if filename.startswith("_"): # App agent: "_app_name" -> "app:name" @@ -51,7 +51,7 @@ def _agent_matches_filter( """Check if an agent output file matches the filter. Args: - filename: Filename stem (e.g., "entities" or "_todos_review") + filename: Filename stem (e.g., "entities" or "_activities_review") agent_filter: Dict mapping agent keys to bool/"required", or None for all Returns: diff --git a/solstone/think/cortex.py b/solstone/think/cortex.py index 202831dc5..7494f727d 100644 --- a/solstone/think/cortex.py +++ b/solstone/think/cortex.py @@ -295,7 +295,7 @@ class CortexService: env = os.environ.copy() # Promote top-level config keys to environment so tools can read - # them as defaults (e.g., sol call todos add uses SOL_FACET). + # them as defaults (e.g., sol call entities search uses SOL_FACET). # Explicit env overrides below take precedence. if config.get("facet"): env["SOL_FACET"] = str(config["facet"]) diff --git a/solstone/think/facets.py b/solstone/think/facets.py index ff29a0210..e746783f8 100644 --- a/solstone/think/facets.py +++ b/solstone/think/facets.py @@ -168,7 +168,7 @@ def _write_action_log( Args: facet: Facet name where the action occurred, or None for journal-level - action: Action type (e.g., "todo_add", "entity_attach") + action: Action type (e.g., "entity_attach", "identity_update") params: Dictionary of action-specific parameters source: Origin type - "tool" for agents, "app" for web UI actor: For tools: agent name. For apps: app name @@ -217,7 +217,7 @@ def log_call_action( """Log an action from a ``sol call`` CLI command. Creates a JSONL log entry for tracking successful modifications made via - ``sol call`` subcommands (entities, todos, etc.). + ``sol call`` subcommands (entities, activities, etc.). When facet is provided, writes to facets/{facet}/logs/{day}.jsonl. When facet is None, writes to config/actions/{day}.jsonl for journal-level @@ -225,7 +225,7 @@ def log_call_action( Args: facet: Facet name where the action occurred, or None for journal-level - action: Action type (e.g., "todo_add", "entity_attach") + action: Action type (e.g., "entity_attach", "identity_update") params: Dictionary of action-specific parameters day: Day in YYYYMMDD format (defaults to today) """ @@ -1161,7 +1161,7 @@ def format_logs( params = entry.get("params", {}) use_id = entry.get("use_id") - # Format action name for display (e.g., "todo_add" -> "Todo Add") + # Format action name for display (e.g., "entity_attach" -> "Entity Attach") action_display = action.replace("_", " ").title() # Build markdown diff --git a/solstone/think/formatters.py b/solstone/think/formatters.py index 34419b02b..a8f406e14 100644 --- a/solstone/think/formatters.py +++ b/solstone/think/formatters.py @@ -88,7 +88,7 @@ def extract_path_metadata(rel_path: str) -> dict[str, str]: # Extract facet from facets/{facet}/... paths if parts[0] == "facets" and len(parts) >= 3: facet = parts[1] - # Day from YYYYMMDD filename (events/entities/todos/news) + # Day from YYYYMMDD filename (events/entities/news) if len(parts) >= 4 and DATE_RE.fullmatch(basename): day = basename # Day from activities/{YYYYMMDD}/{activity_id}/... directory structure @@ -164,7 +164,6 @@ FORMATTERS: dict[str, tuple[str, str, bool]] = { "format_activities", True, ), - "facets/*/todos/*.jsonl": ("solstone.apps.todos.todo", "format_todos", True), "facets/*/logs/*.jsonl": ("solstone.think.facets", "format_logs", True), # Structured file imports (indexed) "*/import.*/imported.jsonl": ( diff --git a/solstone/think/indexer/cli.py b/solstone/think/indexer/cli.py index 50716594b..e41177e03 100644 --- a/solstone/think/indexer/cli.py +++ b/solstone/think/indexer/cli.py @@ -81,7 +81,7 @@ def _display_search_results( def main() -> None: """Main CLI entry point for the indexer.""" parser = argparse.ArgumentParser( - description="Index journal content (insights, transcripts, events, entities, todos)" + description="Index journal content (insights, transcripts, events, entities)" ) parser.add_argument( "--rescan", diff --git a/solstone/think/indexer/journal.py b/solstone/think/indexer/journal.py index 39dd931ec..5a974246c 100644 --- a/solstone/think/indexer/journal.py +++ b/solstone/think/indexer/journal.py @@ -7,7 +7,6 @@ This module provides a single FTS5 index over journal content: - Agent outputs (markdown files) - Events (facet event JSONL) - Entities (facet entity JSONL) -- Todos (facet todo JSONL) - Action logs (facet/journal-level JSONL) All content is converted to markdown chunks via the formatters framework, diff --git a/solstone/think/merge.py b/solstone/think/merge.py index 48f0cd756..c127fdbb8 100644 --- a/solstone/think/merge.py +++ b/solstone/think/merge.py @@ -594,46 +594,6 @@ def _merge_overlapping_facet( f"facet {facet_name} detected entities {source_det_file.name}: {exc}" ) - source_todos_dir = source_facet_dir / "todos" - if source_todos_dir.is_dir(): - for source_todo_file in sorted(source_todos_dir.glob("*.jsonl")): - try: - target_todo_file = target_facet_dir / "todos" / source_todo_file.name - target_items = _read_jsonl(target_todo_file) - seen = {(item["text"], item.get("created_at")) for item in target_items} - new_items = [] - for item in _read_jsonl(source_todo_file): - log_id = f"{facet_name}/todos/{source_todo_file.name}/{item.get('text', '')}" - if (item["text"], item.get("created_at")) in seen: - _log_decision( - log_path, - { - "action": "facet_todo_merged", - "item_type": "todo", - "item_id": log_id, - "reason": "duplicate_skip", - }, - ) - else: - new_items.append(item) - _log_decision( - log_path, - { - "action": "facet_todo_merged", - "item_type": "todo", - "item_id": log_id, - "reason": "appended", - }, - ) - if new_items and not dry_run: - _append_jsonl(target_todo_file, new_items) - except DecisionLogWriteError: - raise - except Exception as exc: - summary.errors.append( - f"facet {facet_name} todo {source_todo_file.name}: {exc}" - ) - source_activities_dir = source_facet_dir / "activities" if source_activities_dir.is_dir(): source_config_file = source_activities_dir / "activities.jsonl" diff --git a/solstone/think/planner.md b/solstone/think/planner.md index d63b87c48..29bf3c190 100644 --- a/solstone/think/planner.md +++ b/solstone/think/planner.md @@ -15,7 +15,7 @@ You are a strategic research planner for the solstone journal assistant, special You have knowledge of these tools for planning purposes: ### Search Tools -- **search_journal**: Unified full-text search across all journal content (agent outputs, events, entities, todos). Supports filtering by `day`, `facet`, and `agent` (e.g., "event", "meetings", "news"). Best for discovering themes, concepts, patterns, and specific content across the journal. Note: raw audio/screen transcripts are not indexed โ€” use `sol call transcripts read` for transcript content. +- **search_journal**: Unified full-text search across all journal content (agent outputs, events, entities). Supports filtering by `day`, `facet`, and `agent` (e.g., "event", "meetings", "news"). Best for discovering themes, concepts, patterns, and specific content across the journal. Note: raw audio/screen transcripts are not indexed โ€” use `sol call transcripts read` for transcript content. ### Content Access - **sol call journal read DAY AGENT**: Read full agent output markdown for a specific day and agent (e.g., `sol call journal read 20240115 meetings`) diff --git a/solstone/think/tools/call.py b/solstone/think/tools/call.py index 6c56a36db..1e85e4768 100644 --- a/solstone/think/tools/call.py +++ b/solstone/think/tools/call.py @@ -357,7 +357,7 @@ def delete( if not yes: typer.echo( f"This will permanently delete facet '{name}' and all its data " - "(entities, todos, events, logs, news).\n" + "(entities, events, logs, news).\n" "Use --yes to confirm." ) raise typer.Exit(1) @@ -381,7 +381,6 @@ def merge( ), ) -> None: """Merge all data from SOURCE facet into DEST facet, then delete SOURCE.""" - from solstone.apps.todos import todo as todo_module from solstone.think.entities.observations import ( load_observations, save_observations, @@ -408,15 +407,6 @@ def merge( entity_slugs = scan_facet_relationships(source) - open_todos: list[tuple[str, int, todo_module.TodoItem]] = [] - todos_dir = src_path / "todos" - if todos_dir.is_dir(): - for todo_file in sorted(todos_dir.glob("*.jsonl")): - checklist = todo_module.TodoChecklist.load(todo_file.stem, source) - for item in checklist.items: - if not item.completed and not item.cancelled: - open_todos.append((todo_file.stem, item.index, item)) - news_to_copy: list[tuple[Path, Path]] = [] src_news_dir = src_path / "news" dst_news_dir = dst_path / "news" @@ -428,8 +418,8 @@ def merge( typer.echo( f"Merging '{source}' into '{dest}': " - f"{len(entity_slugs)} entities, {len(open_todos)} open todos, " - f"{len(news_to_copy)} news files. This cannot be undone. Proceeding..." + f"{len(entity_slugs)} entities, {len(news_to_copy)} news files. " + "This cannot be undone. Proceeding..." ) for entity_id in entity_slugs: @@ -460,35 +450,6 @@ def merge( dst_dir.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(src_dir), str(dst_dir)) - for day, line_number, item in open_todos: - captured_item = item - - def _append_todo( - checklist: todo_module.TodoChecklist, - ) -> tuple[todo_module.TodoChecklist, todo_module.TodoItem]: - new_item = checklist.append_entry( - captured_item.text, - captured_item.nudge, - created_at=captured_item.created_at, - ) - return checklist, new_item - - captured_line_number = line_number - captured_dest = dest - - def _cancel_todo( - checklist: todo_module.TodoChecklist, - ) -> tuple[todo_module.TodoChecklist, todo_module.TodoItem]: - cancelled_item = checklist.cancel_entry( - captured_line_number, - cancelled_reason="moved_to_facet", - moved_to=captured_dest, - ) - return checklist, cancelled_item - - todo_module.TodoChecklist.locked_modify(day, dest, _append_todo) - todo_module.TodoChecklist.locked_modify(day, source, _cancel_todo) - if news_to_copy: dst_news_dir.mkdir(parents=True, exist_ok=True) for src_file, dest_file in news_to_copy: @@ -498,7 +459,6 @@ def merge( "source": source, "dest": dest, "entity_count": len(entity_slugs), - "todo_count": len(open_todos), "news_count": len(news_to_copy), } if consent: diff --git a/solstone/think/tools/search.py b/solstone/think/tools/search.py index 9ef69d627..18a38bc73 100644 --- a/solstone/think/tools/search.py +++ b/solstone/think/tools/search.py @@ -89,7 +89,7 @@ def search_journal( """Search across all journal content using semantic full-text search. This tool searches through all indexed journal content including insights, - transcripts, events, entities, and todos. Use filters to narrow results + transcripts, events, and entities. Use filters to narrow results to specific content types or contexts. Args: diff --git a/tests/baselines/api/config/convey.json b/tests/baselines/api/config/convey.json index 27a2125ea..648a2157c 100644 --- a/tests/baselines/api/config/convey.json +++ b/tests/baselines/api/config/convey.json @@ -4,15 +4,13 @@ "order": [ "home", "activities", - "todos", "entities", "search", "reflections", "news" ], "starred": [ - "home", - "todos" + "home" ] }, "facets": { diff --git a/tests/baselines/api/settings/providers.json b/tests/baselines/api/settings/providers.json index 76426e13b..d2de3e9de 100644 --- a/tests/baselines/api/settings/providers.json +++ b/tests/baselines/api/settings/providers.json @@ -454,29 +454,6 @@ "schedule": "segment", "tier": 3, "type": "generate" - }, - "talent.todos.daily": { - "disabled": false, - "group": "Todos", - "label": "Daily TODO Curator", - "schedule": "daily", - "tier": 2, - "type": "cogitate" - }, - "talent.todos.todo": { - "disabled": false, - "group": "Todos", - "label": "TODO Detector", - "schedule": "activity", - "tier": 2, - "type": "cogitate" - }, - "talent.todos.weekly": { - "disabled": false, - "group": "Todos", - "label": "TODO Weekly Scout", - "tier": 2, - "type": "cogitate" } }, "contexts": { diff --git a/tests/baselines/api/sol/talents-day.json b/tests/baselines/api/sol/talents-day.json index d5b223344..a42bc1919 100644 --- a/tests/baselines/api/sol/talents-day.json +++ b/tests/baselines/api/sol/talents-day.json @@ -379,39 +379,6 @@ "title": "timeline:segment_summary", "type": "generate" }, - "todos:daily": { - "app": "todos", - "color": "#ef6c00", - "description": "Carries forward unfinished tasks, aggregates per-activity todo detections, validates completions against journal evidence, and prioritises the day's checklist.", - "multi_facet": true, - "output_format": null, - "schedule": "daily", - "source": "app", - "title": "Daily TODO Curator", - "type": "cogitate" - }, - "todos:todo": { - "app": "todos", - "color": "#e65100", - "description": "Detects todo items from activity transcripts and validates existing todos against activity evidence via sol call commands.", - "multi_facet": false, - "output_format": null, - "schedule": "activity", - "source": "app", - "title": "TODO Detector", - "type": "cogitate" - }, - "todos:weekly": { - "app": "todos", - "color": "#f4511e", - "description": "Audits the past week's journal follow-ups to confirm completions and surface the next five high-impact todos for today.", - "multi_facet": false, - "output_format": null, - "schedule": null, - "source": "app", - "title": "TODO Weekly Scout", - "type": "cogitate" - }, "weekly_reflection": { "app": null, "color": "#6c757d", diff --git a/tests/baselines/api/todos/badge-count.json b/tests/baselines/api/todos/badge-count.json deleted file mode 100644 index 633d47a56..000000000 --- a/tests/baselines/api/todos/badge-count.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "count": 0 -} diff --git a/tests/baselines/api/todos/nudges.json b/tests/baselines/api/todos/nudges.json deleted file mode 100644 index 15a86c251..000000000 --- a/tests/baselines/api/todos/nudges.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "nudges": [] -} diff --git a/tests/baselines/api/todos/stats-month.json b/tests/baselines/api/todos/stats-month.json deleted file mode 100644 index 8495015f7..000000000 --- a/tests/baselines/api/todos/stats-month.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "20260304": { - "capulet": 2, - "montague": 2 - }, - "20260305": { - "montague": 2, - "verona": 2 - }, - "20260306": { - "capulet": 2, - "montague": 7, - "verona": 1 - }, - "20260307": { - "capulet": 1, - "montague": 2 - }, - "20260308": { - "montague": 2, - "verona": 2 - }, - "20260309": { - "capulet": 2, - "montague": 3, - "verona": 3 - }, - "20260310": { - "capulet": 2, - "montague": 2, - "verona": 2 - } -} diff --git a/tests/baselines/visual/todos/smoke.jpg b/tests/baselines/visual/todos/smoke.jpg deleted file mode 100644 index db4ebc3820aadf1627f33ba555bc7e90ebd69bba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24458 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

    hV?7V5Bo3t@^r6q&wAde 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

    hV?7V5Bo3t@^r6q&wAde zeVcT@ZoHDDxmRlEQ$u6f$&$z3_h;ifRT38IB+yLOSI%5s>;8Cot<5BcqoS@Q!J)QO z-3m_W=)Wua78+9TwNyjX=Rd=v_4Ow8x4BKqw66Y%egCZgcVgNN#M(TWaNnx%z6()d z*Q7bKd1H6IIeK#TmaE2du7*@Cvz;)h*L3;Y=!+6_Vx{y7X8gXEdnBrR)<<#kU(fZ< z{M~)6}(vU zaK@ZCX~wqKrTLExF1z0Px?=vlQ0e8GcjehHAv|5N)?4!Ts{ahr!n1>p)xFyPbV3dT zON8%@D$TE#r8fV{tJF=!%AA3CBw%VfRtLCdkUCXV~=6kJqZhP5wreXVq>1!XnOVm1cv`p=2nC^dug+8Hz zhEsfl=PxysJG1%g_5@RlUhaJ1tCy?ZoZWHoddsRA%Qa`L4!?B%>W{jd2PSE1=O!!` z?>X+KwoAviqhqs3-mFE%Z|DBr|0UNUW6Qjzo!xtTXGh#ycFjn$d&}7wtJb?2^{hHy zlK1A%Z)siS=G)4=vT4hM>$>mGe7@SYJHgaqFaMUvOIu2R?Jf{xYm z_1#iKv*b1XeEaI#>(gRqZ+3TUT(>49_Q+$a8_VvNtz0!b(Dl@!6{jz+xA)fg;r93K z3trZ>%&{V=T}R%EWgH4`U3PW1Vg7|x%8!5Sl|TI{;9x?bl)t3d#;r-~9%dYj*%(@0 za5*U{dsewyeb<_mbDo*!wZ|}rw;V{;iP|Zey!Lu|a8TD{UXddet2*X>{&l2ATrc{_ z+0e5RU7E|XmSuKspSQ;SQQzI4fA(o3Vw*awt~XyNo2~r%PqROyn3?l;SNgq~tACw2 z|7WR^QP1KHtWx{C_x`!~pCL!*cG&74tM8wc|90r?z60`OPTbytH;{PT1CQ#M=N zdivn1FnaY31?#36()o;*w9slmF|KDQy z583hW-WICA47GY7!!^ z?Z12Lk^iMpTSr59G&POpoY7KZv_zyvos)O{$LnPs-vXuoGpzaK=_+#3FXULj((U1Q zj?aISd-c2F^WRJ#7E9cE{-2>)u6|?et@m!hQd@pKvQ0Q^EP8j{jyJ1{|1-4C61crI zX$Ct=&5NjoD^?j-tBAf@Y*L(=7Uvkf%4%tY=&!}AA{gUGnG}bEfa{Vew@)fxVHUYO Q$s Built from 14 transcript segments, 3 anticipated activities, 3 entity profiles, 2 facet newsletters, 5 follow-ups, 8 todos. No gaps. +> Built from 14 transcript segments, 3 anticipated activities, 3 entity profiles, 2 facet newsletters, and 5 follow-ups. No gaps. ## Your Day diff --git a/tests/fixtures/journal/reflections/weekly/20260308.md b/tests/fixtures/journal/reflections/weekly/20260308.md index 78566f56f..a1e3d12e3 100644 --- a/tests/fixtures/journal/reflections/weekly/20260308.md +++ b/tests/fixtures/journal/reflections/weekly/20260308.md @@ -8,31 +8,30 @@ sources: activities: 4 decisions: 1 followups: 2 - todos: 2 relationship_signals: 2 gaps: [] --- -> Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, 2 todos, and 2 relationship signals. No gaps. +> Built from 3 facet newsletters, 4 activity signals, 1 decision thread, 2 follow-up signals, and 2 relationship signals. No gaps. ## This week -- The week had a boardroom balcony inflection: the secret collaboration moved from furtive sprint energy to a board-approved joint venture, which changed the emotional center of the work from urgency to stewardship (sol://20260308/talents/flow; sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). -- Sunday still sounded like a narrow demo push with two days to prove the idea; by Tuesday the same thread had become a public platform launch with both boards aligned behind it (sol://20260308/talents/flow; sol://facets/verona/events/20260310). +- The week had a boardroom balcony inflection: the secret collaboration moved from furtive sprint energy to a board-approved joint venture, which changed the emotional center of the work from urgency to stewardship (sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). +- Sunday still sounded like a narrow demo push with two days to prove the idea; by Tuesday the same thread had become a public platform launch with both boards aligned behind it (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). ## Follow-ups -- Approval did not clear the deck as much as it reframed it. The remaining work shifted from "can this happen?" to "can this survive contact with a real organization?" through presentation follow-through and team formation (sol://facets/verona/todos/20260308; sol://facets/verona/todos/20260310). -- The open loop is less about winning the room now and more about building enough operating structure to deserve the win you just got (sol://facets/verona/todos/20260310). +- Approval did not clear the deck as much as it reframed it. The remaining work shifted from "can this happen?" to "can this survive contact with a real organization?" through presentation follow-through and team formation (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). +- The open loop is less about winning the room now and more about building enough operating structure to deserve the win you just got (sol://facets/verona/news/20260310). ## Decisions -- The decisive move was to bet on technical proof and coalition-building instead of trying to outmaneuver the political noise around Paris Duke's alternative. That choice shows up both in the Sunday plan and in the approval outcome (sol://20260308/talents/flow; sol://facets/montague/news/20260310). +- The decisive move was to bet on technical proof and coalition-building instead of trying to outmaneuver the political noise around Paris Duke's alternative. That choice shows up both in the Sunday plan and in the approval outcome (sol://facets/verona/events/20260308; sol://facets/montague/news/20260310). ## Relationships -- Juliet shifted from co-conspirator to explicit co-lead. The relationship signal is no longer hidden alignment; it is shared ownership in public, which raises both trust and scrutiny (sol://20260308/talents/flow; sol://facets/verona/news/20260310). +- Juliet shifted from co-conspirator to explicit co-lead. The relationship signal is no longer hidden alignment; it is shared ownership in public, which raises both trust and scrutiny (sol://facets/verona/events/20260308; sol://facets/verona/news/20260310). - Friar Lawrence remained the strategic adult in the room: part sponsor, part translator, part stabilizer. His presence on the Sunday strategy call suggests the work needed legitimacy as much as speed (sol://facets/verona/events/20260308). ## Wins - The obvious win is the joint venture approval itself, but the deeper win is that the demo established the Verona Platform as the credible path, not just the romantic one (sol://facets/montague/news/20260310; sol://facets/verona/news/20260310). -- The week also ended with a cleaner story about the work: a platform with real performance numbers, named leaders, and a next chapter that can be staffed instead of improvised (sol://facets/verona/news/20260310; sol://facets/verona/todos/20260310). +- The week also ended with a cleaner story about the work: a platform with real performance numbers, named leaders, and a next chapter that can be staffed instead of improvised (sol://facets/verona/news/20260310; sol://facets/montague/news/20260310). ## Forward look -- The next week should be less about adrenaline and more about operational discipline. Hiring, security, and post-board execution are now the tests that decide whether this was a breakthrough or a beautiful spike (sol://facets/verona/todos/20260310). +- The next week should be less about adrenaline and more about operational discipline. Hiring, security, and post-board execution are now the tests that decide whether this was a breakthrough or a beautiful spike (sol://facets/verona/news/20260310). diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index f43bac8b4..7d5dd5afc 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -152,10 +152,10 @@ def test_facet_action_includes_facet_field(test_facet): # Log a facet-scoped action log_app_action( - app="todos", + app="entities", facet=facet, - action="todo_add", - params={"text": "Test todo"}, + action="entity_attach", + params={"type": "Person", "name": "Test Entity"}, ) # Check that the facet field is included diff --git a/tests/test_app_reflections.py b/tests/test_app_reflections.py index e2c8ee42f..8c4bc4745 100644 --- a/tests/test_app_reflections.py +++ b/tests/test_app_reflections.py @@ -272,7 +272,6 @@ sources: activities: 0 decisions: 0 followups: 0 - todos: 0 relationship_signals: 0 gaps: [] --- diff --git a/tests/test_call.py b/tests/test_call.py index ceec77e2c..c3a92e612 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -99,13 +99,6 @@ def merge_journal(tmp_path, monkeypatch): encoding="utf-8", ) - src_todos_dir = src_dir / "todos" - src_todos_dir.mkdir(parents=True) - (src_todos_dir / "20260101.jsonl").write_text( - json.dumps({"text": "Move the roadmap", "created_at": 1000}) + "\n", - encoding="utf-8", - ) - src_news_dir = src_dir / "news" dst_news_dir = dst_dir / "news" src_news_dir.mkdir(parents=True) @@ -157,12 +150,6 @@ class TestDiscovery: result = runner.invoke(call_app, []) assert "Call app functions" in result.output - def test_todos_app_discovered(self): - """The todos app should be auto-discovered.""" - result = runner.invoke(call_app, ["todos", "--help"]) - assert result.exit_code == 0 - assert "list" in result.output - def test_unknown_app_fails(self): """Unknown app name should produce an error.""" result = runner.invoke(call_app, ["nonexistent"]) @@ -750,37 +737,6 @@ class TestFacetMerge: "Dest fact", } - def test_merge_moves_open_todos(self, merge_journal, monkeypatch): - """Merge appends open todos to destination and cancels them in source.""" - self._mock_indexer(monkeypatch) - import solstone.think.tools.call as call_module - - monkeypatch.setattr(call_module, "delete_facet", lambda *args, **kwargs: None) - - result = runner.invoke( - call_app, - ["journal", "facet", "merge", "src-facet", "--into", "dst-facet"], - ) - - assert result.exit_code == 0 - dst_todos = ( - (merge_journal / "facets" / "dst-facet" / "todos" / "20260101.jsonl") - .read_text(encoding="utf-8") - .splitlines() - ) - assert any(json.loads(line)["text"] == "Move the roadmap" for line in dst_todos) - src_payloads = [ - json.loads(line) - for line in ( - merge_journal / "facets" / "src-facet" / "todos" / "20260101.jsonl" - ) - .read_text(encoding="utf-8") - .splitlines() - ] - assert src_payloads[0]["cancelled"] is True - assert src_payloads[0]["cancelled_reason"] == "moved_to_facet" - assert src_payloads[0]["moved_to"] == "dst-facet" - def test_merge_copies_news_skips_conflicts(self, merge_journal, monkeypatch): """Merge copies unique news files and preserves destination conflicts.""" self._mock_indexer(monkeypatch) @@ -836,7 +792,6 @@ class TestFacetMerge: assert merge_entry["params"]["source"] == "src-facet" assert merge_entry["params"]["dest"] == "dst-facet" assert merge_entry["params"]["entity_count"] == 1 - assert merge_entry["params"]["todo_count"] == 1 assert merge_entry["params"]["news_count"] == 1 def test_merge_same_facet_error(self, merge_journal): diff --git a/tests/test_check_cogitate_prompts.py b/tests/test_check_cogitate_prompts.py index dad678278..ad47b4390 100644 --- a/tests/test_check_cogitate_prompts.py +++ b/tests/test_check_cogitate_prompts.py @@ -68,7 +68,6 @@ def _patch_talent_dirs( ("Bad `Read('x')`.", "cli-agent-tool"), ("Bad `cat foo`.", "shell-read"), ("Bad `journal/chronicle/20260101/x.json`.", "raw-journal-path"), - ("Bad `sol call todos list -d 20260101`.", "unsupported-flag"), ], ) def test_lint_prompt_flags_cogitate_policy_violations(body: str, kind: str) -> None: @@ -117,7 +116,7 @@ def test_lint_prompt_ignores_sanctioned_forms(body: str) -> None: @pytest.mark.parametrize( "body", [ - "`sol call journal search x && sol call todos list`", + "`sol call journal search x && sol call entities list`", "`echo $(sol call support create --subject x)`", "`bash -lc 'sol call journal search x'`", ], @@ -154,7 +153,7 @@ def test_lint_prompt_allows_multiline_partner_value_example() -> None: body = """```bash journal identity partner --update-section 'work patterns' --value 'My partner tends to batch meetings before noon and protects afternoon blocks for focused work. Calendar data from March 25-31 shows 85% of meetings before 12:00 (sol://20260328/archon/091500_300). -Deep work sessions typically run 2-3 hours โ€” todo completion spikes correlate with these blocks.' +Deep work sessions typically run 2-3 hours โ€” calendar and activity signals show fewer interruptions during these blocks.' ``` """ diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 58d237f94..ba05aee42 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -530,7 +530,9 @@ def test_cluster_with_agent_filter_app_namespaced(tmp_path, monkeypatch): (segment / "talents").mkdir() (segment / "audio.jsonl").write_text('{}\n{"text": "hello"}\n') (segment / "talents" / "entities.md").write_text("System entity results") - (segment / "talents" / "_todos_review.md").write_text("Todos review results") + (segment / "talents" / "_activities_review.md").write_text( + "Activities review results" + ) # Test filtering to include app-namespaced agent result, counts = mod.cluster( @@ -538,14 +540,14 @@ def test_cluster_with_agent_filter_app_namespaced(tmp_path, monkeypatch): sources={ "transcripts": True, "percepts": False, - "agents": {"entities": False, "todos:review": True}, + "agents": {"entities": False, "activities:review": True}, }, ) assert counts["transcripts"] == 1 - assert counts["agents"] == 1 # Only todos:review + assert counts["agents"] == 1 # Only activities:review assert "System entity results" not in result - assert "Todos review results" in result + assert "Activities review results" in result def test_cluster_with_empty_agent_filter(tmp_path, monkeypatch): @@ -581,7 +583,7 @@ def test_filename_to_agent_key(): assert _filename_to_agent_key("flow") == "flow" # App-namespaced agents - assert _filename_to_agent_key("_todos_review") == "todos:review" + assert _filename_to_agent_key("_activities_review") == "activities:review" assert _filename_to_agent_key("_entities_observer") == "entities:observer" # Edge case: single underscore component @@ -594,17 +596,17 @@ def test_agent_matches_filter(): # None filter means all agents assert _agent_matches_filter("entities", None) is True - assert _agent_matches_filter("_todos_review", None) is True + assert _agent_matches_filter("_activities_review", None) is True # Empty dict means no agents assert _agent_matches_filter("entities", {}) is False - assert _agent_matches_filter("_todos_review", {}) is False + assert _agent_matches_filter("_activities_review", {}) is False # Specific filtering - filter_dict = {"entities": True, "meetings": False, "todos:review": "required"} + filter_dict = {"entities": True, "meetings": False, "activities:review": "required"} assert _agent_matches_filter("entities", filter_dict) is True assert _agent_matches_filter("meetings", filter_dict) is False - assert _agent_matches_filter("_todos_review", filter_dict) is True + assert _agent_matches_filter("_activities_review", filter_dict) is True assert _agent_matches_filter("flow", filter_dict) is False # Not in filter diff --git a/tests/test_cogitate_policy.py b/tests/test_cogitate_policy.py index 2d764085d..09d7cf1e3 100644 --- a/tests/test_cogitate_policy.py +++ b/tests/test_cogitate_policy.py @@ -102,7 +102,7 @@ def test_policy_denies_unapproved_journal_invocations(tmp_path, command): "sol call journal search x > out", "sol call journal search x 2>&1", "sol call journal search x <(journal health)", - "sol call journal search x\nsol call todos list", + "sol call journal search x\nsol call entities list", "sol call journal search 'unterminated", ], ) @@ -252,7 +252,7 @@ def test_policy_allows_non_support_chain_for_normal(tmp_path): allowed, reason = policy.check( "run_shell_command", - {"command": "sol call activities list && sol call todos list"}, + {"command": "sol call activities list && sol call entities list"}, ) assert allowed is False diff --git a/tests/test_export.py b/tests/test_export.py index 005bc5ff5..948c09a05 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -123,10 +123,6 @@ def _setup_facets(tmp_path): det_dir = work_dir / "entities" (det_dir / "20260413.jsonl").write_text('{"entity": "test"}\n', encoding="utf-8") - todo_dir = work_dir / "todos" - todo_dir.mkdir(parents=True) - (todo_dir / "20260413.jsonl").write_text('{"task": "do stuff"}\n', encoding="utf-8") - personal_dir = facets_dir / "personal" personal_dir.mkdir(parents=True) (personal_dir / "facet.json").write_text('{"title": "Personal"}', encoding="utf-8") @@ -749,7 +745,6 @@ class TestExportFacets: "files_0_1", "files_0_2", "files_0_3", - "files_0_4", ] work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"]) assert work_metadata == { @@ -770,7 +765,6 @@ class TestExportFacets: "type": "entity_observations", }, {"path": "facet.json", "type": "facet_json"}, - {"path": "todos/20260413.jsonl", "type": "todos"}, ], } ] @@ -791,7 +785,6 @@ class TestExportFacets: work_dir / "entities" / "alice" / "entity.json" ), "work/entities/alice/observations.jsonl": "stale-hash", - "work/todos/20260413.jsonl": "stale-hash", "personal/facet.json": _facet_file_hash(personal_dir / "facet.json"), "personal/news/20260413.md": _facet_file_hash( personal_dir / "news" / "20260413.md" @@ -815,7 +808,6 @@ class TestExportFacets: "path": "entities/alice/observations.jsonl", "type": "entity_observations", }, - {"path": "todos/20260413.jsonl", "type": "todos"}, ] def test_dry_run(self, tmp_path, monkeypatch, capsys): @@ -834,9 +826,9 @@ class TestExportFacets: assert mock_session.post.call_count == 0 output = capsys.readouterr().out assert "personal: 2 new, 0 changed, 0 unchanged" in output - assert "work: 5 new, 0 changed, 0 unchanged" in output + assert "work: 4 new, 0 changed, 0 unchanged" in output assert ( - "Dry run: 7 new files, 0 changed, 0 unchanged across 2 facet(s)" in output + "Dry run: 6 new files, 0 changed, 0 unchanged across 2 facet(s)" in output ) def test_idempotent(self, tmp_path, monkeypatch, capsys): @@ -917,7 +909,7 @@ class TestExportFacets: assert mock_session.post.call_count == 0 output = capsys.readouterr().out assert "personal: 2 new, 0 changed, 0 unchanged" in output - assert "work: 0 new, 5 changed, 0 unchanged" in output + assert "work: 0 new, 4 changed, 0 unchanged" in output def test_skips_invalid_facet_names(self, tmp_path, monkeypatch): from solstone.observe.export import export_facets diff --git a/tests/test_export_integration.py b/tests/test_export_integration.py index 680ae81dc..6cd3ab129 100644 --- a/tests/test_export_integration.py +++ b/tests/test_export_integration.py @@ -246,10 +246,6 @@ def _setup_facet_with_entity( facet_root / "entities" / "20260413.jsonl", [{"id": entity_id, "name": "Source Entity", "type": "Person"}], ) - _write_jsonl( - facet_root / "todos" / "20260413.jsonl", - [{"text": "Follow up", "created_at": 1}], - ) def _setup_simple_facet(journal_root: Path) -> None: diff --git a/tests/test_facet_rename.py b/tests/test_facet_rename.py index 7386a8b07..e1ee2b2cb 100644 --- a/tests/test_facet_rename.py +++ b/tests/test_facet_rename.py @@ -23,8 +23,10 @@ def journal(tmp_path, monkeypatch): ) # Create some facet content to verify it moves - (facet_dir / "todos").mkdir() - (facet_dir / "todos" / "20260101.jsonl").write_text('{"text": "Buy groceries"}\n') + (facet_dir / "entities").mkdir() + (facet_dir / "entities" / "20260101.jsonl").write_text( + '{"type": "Person", "name": "Alice"}\n' + ) return tmp_path @@ -38,8 +40,8 @@ def test_rename_moves_directory(journal): assert (journal / "facets" / "new-name" / "facet.json").exists() # Content preserved - todos = journal / "facets" / "new-name" / "todos" / "20260101.jsonl" - assert todos.read_text().strip() == '{"text": "Buy groceries"}' + entities = journal / "facets" / "new-name" / "entities" / "20260101.jsonl" + assert entities.read_text().strip() == '{"type": "Person", "name": "Alice"}' def test_rename_updates_convey_config_selected(journal): diff --git a/tests/test_facets.py b/tests/test_facets.py index af3de85ed..797d2b890 100644 --- a/tests/test_facets.py +++ b/tests/test_facets.py @@ -345,10 +345,10 @@ def test_find_orphan_facets_fixture_excludes_broken_facet(monkeypatch): def test_ensure_facet_repairs_orphan_visible_in_get_facets(tmp_path, monkeypatch): """ensure_facet writes default metadata once and makes the facet visible.""" monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - todos_dir = tmp_path / "facets" / "orphan" / "todos" - todos_dir.mkdir(parents=True) - (todos_dir / "20260101.jsonl").write_text( - json.dumps({"text": "Review"}) + "\n", encoding="utf-8" + entities_dir = tmp_path / "facets" / "orphan" / "entities" + entities_dir.mkdir(parents=True) + (entities_dir / "20260101.jsonl").write_text( + json.dumps({"type": "Person", "name": "Alice"}) + "\n", encoding="utf-8" ) assert "orphan" not in get_facets() diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 045ac635f..86365c1ed 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -858,143 +858,6 @@ class TestFormatObservations: assert "Allergic to peanuts" in chunks[2]["markdown"] -class TestFormatTodos: - """Tests for the todos formatter.""" - - def test_get_formatter_todos(self): - """Test pattern matching for todos/*.jsonl.""" - from solstone.think.formatters import get_formatter - - formatter = get_formatter("facets/personal/todos/20240101.jsonl") - assert formatter is not None - assert formatter.__name__ == "format_todos" - - def test_format_todos_basic(self): - """Test basic todos formatting with fixture file.""" - from solstone.think.formatters import format_file - - path = ( - Path(os.environ["SOLSTONE_JOURNAL"]) - / "facets/personal/todos/20240101.jsonl" - ) - chunks, meta = format_file(path) - - assert len(chunks) == 4 # 4 items in fixture - assert "header" in meta - assert "Todos: personal" in meta["header"] - assert "2024-01-01" in meta["header"] - assert "4 items" in meta["header"] - - def test_format_todos_direct(self): - """Test format_todos function directly.""" - from solstone.apps.todos.todo import format_todos - - entries = [ - {"text": "Do something", "completed": False}, - {"text": "Done task", "completed": True}, - ] - - chunks, meta = format_todos(entries) - - assert len(chunks) == 2 - assert "* [ ] Do something" in chunks[0]["markdown"] - assert "* [x] Done task" in chunks[1]["markdown"] - - def test_format_todos_list_item_prefix(self): - """Test that all items have * prefix for markdown list.""" - from solstone.apps.todos.todo import format_todos - - entries = [ - {"text": "Task one", "completed": False}, - {"text": "Task two", "completed": True}, - {"text": "Cancelled", "cancelled": True}, - ] - - chunks, meta = format_todos(entries) - - assert len(chunks) == 3 - for chunk in chunks: - assert chunk["markdown"].startswith("* ") - - def test_format_todos_completed_cancelled_display(self): - """Test checkbox and strikethrough rendering.""" - from solstone.apps.todos.todo import format_todos - - entries = [ - {"text": "Incomplete", "completed": False}, - {"text": "Complete", "completed": True}, - {"text": "Cancelled", "cancelled": True}, - ] - - chunks, meta = format_todos(entries) - - assert "[ ] Incomplete" in chunks[0]["markdown"] - assert "[x] Complete" in chunks[1]["markdown"] - assert "~~[cancelled] Cancelled~~" in chunks[2]["markdown"] - - def test_format_todos_with_time(self): - """Test formatting with legacy time annotation input.""" - from solstone.apps.todos.todo import format_todos - - entries = [{"text": "Meeting", "time": "14:00", "completed": False}] - context = {"file_path": "/journal/facets/work/todos/20251215.jsonl"} - - chunks, meta = format_todos(entries, context) - - assert len(chunks) == 1 - assert "Meeting (" in chunks[0]["markdown"] - - def test_format_todos_header_facet_from_path(self): - """Test that facet name and day are extracted from file path.""" - from solstone.apps.todos.todo import format_todos - - entries = [{"text": "Test", "completed": False}] - context = {"file_path": "/journal/facets/work/todos/20251215.jsonl"} - - chunks, meta = format_todos(entries, context) - - assert "Todos: work" in meta["header"] - assert "2025-12-15" in meta["header"] - - def test_format_todos_skipped_entries_error(self): - """Test that entries without 'text' field are skipped and reported.""" - from solstone.apps.todos.todo import format_todos - - entries = [ - {"text": "Valid", "completed": False}, - {"invalid": "no text"}, - {"also_invalid": True}, - ] - - chunks, meta = format_todos(entries) - - assert len(chunks) == 1 - assert "error" in meta - assert "Skipped 2 entries" in meta["error"] - assert "text" in meta["error"] - - def test_format_todos_timestamp_fallback(self): - """Test timestamp fallback: updated_at -> created_at -> file mtime.""" - from solstone.apps.todos.todo import format_todos - - # Entry with updated_at takes priority - entries_updated = [ - {"text": "Test", "updated_at": 1700000000000, "created_at": 1600000000000} - ] - chunks, _ = format_todos(entries_updated) - assert chunks[0]["timestamp"] == 1700000000000 - - # Entry with only created_at - entries_created = [{"text": "Test", "created_at": 1600000000000}] - chunks, _ = format_todos(entries_created) - assert chunks[0]["timestamp"] == 1600000000000 - - # Entry with neither uses 0 (no file context) - entries_none = [{"text": "Test"}] - chunks, _ = format_todos(entries_none) - assert chunks[0]["timestamp"] == 0 - - class TestFormatEvents: """Tests for the events formatter.""" @@ -1655,16 +1518,6 @@ class TestFormatterIndexerMetadata: assert "indexer" in meta assert meta["indexer"]["agent"] == "entity:detected" - def test_format_todos_returns_indexer(self): - """Test format_todos returns indexer with agent.""" - from solstone.apps.todos.todo import format_todos - - entries = [{"text": "Test task", "completed": False}] - chunks, meta = format_todos(entries) - - assert "indexer" in meta - assert meta["indexer"]["agent"] == "todo" - class TestFormatterSourceKey: """Tests verifying formatters return source key with original entry.""" @@ -1760,19 +1613,6 @@ class TestFormatterSourceKey: assert chunks[0]["source"] is entity assert chunks[0]["source"]["custom"] == 123 - def test_format_todos_returns_source(self): - """Test format_todos returns source with original entry.""" - from solstone.apps.todos.todo import format_todos - - entry = {"text": "Test task", "completed": False, "priority": "high"} - entries = [entry] - chunks, meta = format_todos(entries) - - assert len(chunks) == 1 - assert "source" in chunks[0] - assert chunks[0]["source"] is entry - assert chunks[0]["source"]["priority"] == "high" - def test_format_audio_timestamp_in_milliseconds(self): """Test format_audio returns timestamp in milliseconds.""" from solstone.observe.hear import format_audio @@ -1821,20 +1661,21 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos:todo", - "action": "todo_add", - "params": {"text": "Test task"}, + "actor": "entities:entities", + "action": "entity_attach", + "params": {"type": "Person", "name": "Alice"}, } ] chunks, meta = format_logs(entries) assert len(chunks) == 1 - assert "Todo Add by todos:todo" in chunks[0]["markdown"] + assert "Entity Attach by entities:entities" in chunks[0]["markdown"] assert "**Source:** tool" in chunks[0]["markdown"] assert "**Time:** 07:33:05" in chunks[0]["markdown"] assert "**Parameters:**" in chunks[0]["markdown"] - assert "- text: Test task" in chunks[0]["markdown"] + assert "- type: Person" in chunks[0]["markdown"] + assert "- name: Alice" in chunks[0]["markdown"] def test_format_logs_with_agent_id(self): """Test that use_id renders as a link.""" @@ -1867,14 +1708,14 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos", - "action": "todo_add", + "actor": "entities", + "action": "entity_attach", "params": {}, }, { "timestamp": "2025-12-16T07:34:00.000000+00:00", "source": "tool", - "actor": "todos", + "actor": "entities", # Missing action "params": {}, }, @@ -1895,8 +1736,8 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos", - "action": "todo_add", + "actor": "entities", + "action": "entity_attach", "params": {}, } ] @@ -1914,8 +1755,8 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "app", - "actor": "todos", - "action": "todo_complete", + "actor": "entities", + "action": "entity_detach", "params": {}, } ] @@ -1934,9 +1775,9 @@ class TestFormatLogs: entry = { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos", - "action": "todo_add", - "params": {"text": "Test"}, + "actor": "entities", + "action": "entity_attach", + "params": {"name": "Test"}, "extra_field": "custom_value", } entries = [entry] @@ -1956,15 +1797,15 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos", - "action": "todo_add", + "actor": "entities", + "action": "entity_attach", "params": {}, }, { "timestamp": "2025-12-16T07:34:00.000000+00:00", "source": "tool", - "actor": "todos", - "action": "todo_done", + "actor": "entities", + "action": "entity_update", "params": {}, }, ] @@ -1987,9 +1828,9 @@ class TestFormatLogs: { "timestamp": "2025-12-16T07:33:05.135587+00:00", "source": "tool", - "actor": "todos", - "action": "todo_add", - "params": {"text": long_text}, + "actor": "entities", + "action": "entity_attach", + "params": {"name": long_text}, } ] diff --git a/tests/test_home_needs_you.py b/tests/test_home_needs_you.py index 75c9c3b90..4506570ba 100644 --- a/tests/test_home_needs_you.py +++ b/tests/test_home_needs_you.py @@ -19,14 +19,11 @@ from solstone.apps.home.needs_you import ( def test_classify_needs_you_locked_shape_and_order(): attention = {"placeholder_text": "Pipeline needs review"} pulse_needs = ["Review the launch checklist"] - todos = [{"text": "Send the partner update"}] - - items = classify_needs_you(attention, pulse_needs, todos) + items = classify_needs_you(attention, pulse_needs) assert [item.text for item in items] == [ "Pipeline needs review", "Review the launch checklist", - "Send the partner update", ] assert [field.name for field in fields(NeedsYouItem)] == [ "text", @@ -49,7 +46,6 @@ def test_classify_needs_you_warns_and_omits_malformed(caplog): items = classify_needs_you( None, [None, ""], - [{"missing_text_field": 1}], ) assert items == [] @@ -70,7 +66,6 @@ def test_classify_needs_you_route_same_origin_only(caplog): "payload": {"href": "/app/settings"}, } ], - [], ) assert route_items == [ @@ -96,7 +91,6 @@ def test_classify_needs_you_invalid_route_returns_disabled_item(): "payload": {"href": "https://evil.com"}, } ], - [], ) assert items == [ @@ -124,7 +118,6 @@ def test_classify_needs_you_folds_confirm_to_chat(): items = classify_needs_you( None, [{"text": "Confirm the next step", "kind": "confirm", "payload": {}}], - [], ) assert items == [ @@ -136,18 +129,6 @@ def test_classify_needs_you_folds_confirm_to_chat(): ] -def test_classify_needs_you_todos_default_to_chat_with_context_prompt(): - items = classify_needs_you(None, [], [{"text": "Draft the launch note"}]) - - assert items == [ - NeedsYouItem( - text="Draft the launch note", - kind="chat", - payload={"prompt": "what's the context on: Draft the launch note"}, - ) - ] - - def test_unknown_kind_renders_inert(): workspace = ( Path(__file__).resolve().parents[1] diff --git a/tests/test_home_reflections.py b/tests/test_home_reflections.py index a2be75a9a..38fa9bdce 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": [], - "todos": [], "routines": [], "briefing_sections": {}, "briefing_meta": None, diff --git a/tests/test_home_routines.py b/tests/test_home_routines.py index 95c94050e..cbb06feec 100644 --- a/tests/test_home_routines.py +++ b/tests/test_home_routines.py @@ -210,7 +210,6 @@ def test_api_pulse_includes_routines(monkeypatch, home_client): monkeypatch.setattr( "solstone.apps.home.routes._collect_activities", lambda today: [] ) - monkeypatch.setattr("solstone.apps.home.routes._collect_todos", lambda today: []) monkeypatch.setattr( "solstone.apps.home.routes._collect_routines", lambda: [ diff --git a/tests/test_home_yesterdays_processing.py b/tests/test_home_yesterdays_processing.py index 9bdcc3464..0202f98eb 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_todos", lambda today: []) monkeypatch.setattr("solstone.apps.home.routes._collect_routines", lambda: []) monkeypatch.setattr( "solstone.apps.home.routes.read_steward_health", @@ -664,7 +663,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_todos", lambda today: []) monkeypatch.setattr("solstone.apps.home.routes._collect_routines", lambda: []) monkeypatch.setattr("solstone.apps.home.routes.read_steward_health", lambda: None) monkeypatch.setattr( diff --git a/tests/test_journal_merge.py b/tests/test_journal_merge.py index 753556ba5..be72fad76 100644 --- a/tests/test_journal_merge.py +++ b/tests/test_journal_merge.py @@ -325,20 +325,6 @@ def test_facet_merge_overlapping(merge_journals_fixture, monkeypatch): {"content": "Target fact", "observed_at": 300}, ], ) - _write_jsonl( - paths["source"] / "facets" / "work" / "todos" / "20260101.jsonl", - [ - {"text": "Duplicate todo", "created_at": 10}, - {"text": "Source todo", "created_at": 11}, - ], - ) - _write_jsonl( - paths["target"] / "facets" / "work" / "todos" / "20260101.jsonl", - [ - {"text": "Duplicate todo", "created_at": 10}, - {"text": "Target todo", "created_at": 12}, - ], - ) (paths["source"] / "facets" / "work" / "news").mkdir(parents=True) (paths["target"] / "facets" / "work" / "news").mkdir(parents=True) (paths["source"] / "facets" / "work" / "news" / "20260101.md").write_text( @@ -445,15 +431,6 @@ def test_facet_merge_overlapping(merge_journals_fixture, monkeypatch): "Target fact", } - todos = _read_jsonl( - paths["target"] / "facets" / "work" / "todos" / "20260101.jsonl" - ) - assert {item["text"] for item in todos} == { - "Duplicate todo", - "Source todo", - "Target todo", - } - assert (paths["target"] / "facets" / "work" / "news" / "20260102.md").read_text( encoding="utf-8" ) == "source new\n" diff --git a/tests/test_openhands_provider.py b/tests/test_openhands_provider.py index 75a7d1ef3..92a7eb7df 100644 --- a/tests/test_openhands_provider.py +++ b/tests/test_openhands_provider.py @@ -688,25 +688,15 @@ def test_schedule_gated_cogitate_prompts_use_emit_final(): if config.get("schedule") in {"daily", "weekly", "activity"} and "output" not in config } - artifact_names = { - name - for name, config in converted.items() - if name.endswith(":todo") and config.get("schedule") == "activity" - } - # steward is no longer a cogitate talent (deterministic render + lite generate). - assert len(converted) == 8 - assert artifact_names == {"todos:todo"} + assert len(converted) == 6 for name, config in converted.items(): body = Path(config["path"]).read_text(encoding="utf-8") assert "emit_final" in body, name assert old_tool_name not in body, name assert "FinishTool" not in body, name - if name in artifact_names: - assert body.count("emit_final") >= 1, name - else: - assert body.count("emit_final") >= 2, name + assert body.count("emit_final") >= 2, name def test_run_cogitate_threads_configured_max_turns( diff --git a/tests/test_post_action_terminology.py b/tests/test_post_action_terminology.py index feb2225f4..566cd91c0 100644 --- a/tests/test_post_action_terminology.py +++ b/tests/test_post_action_terminology.py @@ -30,7 +30,6 @@ def _minimal_home_context() -> dict: item = classify_needs_you( None, ["Review the launch checklist"], - [{"text": "Draft the partner note"}], )[0].to_dict() return { "today": "20260524", @@ -52,7 +51,6 @@ def _minimal_home_context() -> dict: "flow_updated_at": None, "anticipated_activities": [], "activities": [], - "todos": [{"text": "Draft the partner note"}], "needs_you_items": [item], "routines": [], "briefing_sections": {}, @@ -104,7 +102,6 @@ def test_home_needs_you_strings_use_allowed_terms(journal_copy, monkeypatch): classifier_items = classify_needs_you( {"placeholder_text": "Pipeline needs review"}, ["Review the launch checklist"], - [{"text": "Draft the partner note"}], ) for item in classifier_items: _assert_clean(item.text) diff --git a/tests/test_todos_call_parity.py b/tests/test_todos_call_parity.py deleted file mode 100644 index 3f4b9b2ef..000000000 --- a/tests/test_todos_call_parity.py +++ /dev/null @@ -1,939 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import json -from datetime import datetime as RealDateTime -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -import solstone.apps.todos.call as todos_call -import solstone.apps.todos.routes as todos_routes -import solstone.apps.todos.todo as todo_mod -import solstone.think.facets as facets_mod -from solstone.apps.todos.call import app -from solstone.apps.todos.todo import TodoChecklist, TodoItem -from solstone.think.convey_client import ConveyClient -from solstone.think.journal_io import LockTimeout -from tests._baseline_harness import make_logged_in_test_client - -FROZEN_DAY = "20260310" - - -class FixedDateTime(RealDateTime): - @classmethod - def now(cls, tz=None): - return cls(2026, 3, 10, 12, 0, tzinfo=tz) - - -@pytest.fixture -def journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - return tmp_path - - -@pytest.fixture -def runner(journal: Path, monkeypatch: pytest.MonkeyPatch) -> CliRunner: - client = ConveyClient(session=make_logged_in_test_client(journal), base_url="") - monkeypatch.setattr("solstone.apps.todos.call.get_client", lambda: client) - return CliRunner() - - -@pytest.fixture -def frozen_clock(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(todos_call, "datetime", FixedDateTime) - monkeypatch.setattr(todos_routes, "datetime", FixedDateTime) - monkeypatch.setattr(todo_mod, "datetime", FixedDateTime) - monkeypatch.setattr(facets_mod, "datetime", FixedDateTime) - - -def _ensure_facet(journal: Path, facet: str) -> None: - facet_dir = journal / "facets" / facet - facet_dir.mkdir(parents=True, exist_ok=True) - (facet_dir / "facet.json").write_text( - json.dumps( - { - "title": facet.title(), - "description": f"{facet} facet", - "color": "#6b7280", - } - ) - + "\n", - encoding="utf-8", - ) - - -def _write_todos( - journal: Path, - facet: str, - day: str, - entries: list[dict] | None, -) -> Path: - _ensure_facet(journal, facet) - todos_dir = journal / "facets" / facet / "todos" - todos_dir.mkdir(parents=True, exist_ok=True) - path = todos_dir / f"{day}.jsonl" - if entries is not None: - path.write_text( - "".join(json.dumps(entry, ensure_ascii=False) + "\n" for entry in entries), - encoding="utf-8", - ) - return path - - -def _read_jsonl(path: Path) -> list[dict]: - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - - -def _read_logs(journal: Path, facet: str, day: str) -> list[dict]: - return _read_jsonl(journal / "facets" / facet / "logs" / f"{day}.jsonl") - - -def _lock_timeout(journal: Path) -> LockTimeout: - return LockTimeout(journal / "lock", 0.01) - - -def test_list_single_facet_byte_exact(runner: CliRunner, journal: Path) -> None: - _write_todos( - journal, - "personal", - "20240101", - [{"text": "Buy milk"}, {"text": "Walk dog", "completed": True}], - ) - - result = runner.invoke(app, ["list", "20240101", "--facet", "personal"]) - - assert result.exit_code == 0 - assert result.stdout == "1: [ ] Buy milk\n2: [x] Walk dog\n" - assert result.stderr == "" - - -def test_list_single_day_multi_facet_byte_exact( - runner: CliRunner, journal: Path -) -> None: - _write_todos(journal, "work", "20240101", [{"text": "Work task"}]) - _write_todos(journal, "home", "20240101", [{"text": "Home task"}]) - - result = runner.invoke(app, ["list", "20240101"]) - - assert result.exit_code == 0 - assert result.stdout == ( - "## home\n1: [ ] Home task\n\n## work\n1: [ ] Work task\n\n" - ) - assert result.stderr == "" - - -def test_list_range_multi_facet_byte_exact(runner: CliRunner, journal: Path) -> None: - _write_todos(journal, "work", "20240101", [{"text": "Work d1"}]) - _write_todos(journal, "work", "20240102", [{"text": "Work d2"}]) - _write_todos(journal, "home", "20240101", [{"text": "Home d1"}]) - - result = runner.invoke(app, ["list", "20240101", "--to", "20240102"]) - - assert result.exit_code == 0 - assert result.stdout == ( - "## home\n" - "### 20240101\n" - "1: [ ] Home d1\n" - "\n" - "## work\n" - "### 20240101\n" - "1: [ ] Work d1\n" - "\n" - "### 20240102\n" - "1: [ ] Work d2\n" - "\n" - ) - assert result.stderr == "" - - -def test_list_empty_and_invalid_range(runner: CliRunner, journal: Path) -> None: - _ensure_facet(journal, "personal") - _write_todos(journal, "work", "20240101", [{"text": "Work task"}]) - - empty = runner.invoke(app, ["list", "20240101", "--facet", "personal"]) - invalid = runner.invoke( - app, ["list", "20240201", "--facet", "personal", "--to", "20240101"] - ) - malformed_day = runner.invoke(app, ["list", "bad", "--facet", "personal"]) - malformed_to = runner.invoke( - app, ["list", "20240101", "--facet", "work", "--to", "bad"] - ) - - assert empty.exit_code == 0 - assert empty.stdout == "No todos found for 20240101.\n" - assert invalid.exit_code == 1 - assert ( - invalid.stderr == "Error: --to (20240101) must not be before day (20240201)\n" - ) - assert malformed_day.exit_code == 0 - assert malformed_day.stdout == "No todos found for bad.\n" - assert malformed_to.exit_code == 0 - assert malformed_to.stdout == "### 20240101\n1: [ ] Work task\n\n" - - -def test_list_defaults_from_env_and_today( - runner: CliRunner, - journal: Path, - monkeypatch: pytest.MonkeyPatch, - frozen_clock: None, -) -> None: - _write_todos(journal, "personal", "20240101", [{"text": "Env task"}]) - _write_todos(journal, "personal", FROZEN_DAY, [{"text": "Today task"}]) - - monkeypatch.setenv("SOL_DAY", "20240101") - env_result = runner.invoke(app, ["list", "--facet", "personal"]) - monkeypatch.delenv("SOL_DAY", raising=False) - today_result = runner.invoke(app, ["list", "--facet", "personal"]) - - assert env_result.exit_code == 0 - assert env_result.stdout == "1: [ ] Env task\n" - assert today_result.exit_code == 0 - assert today_result.stdout == "1: [ ] Today task\n" - - -def test_list_day_option_matches_positional_and_conflicts( - runner: CliRunner, journal: Path -) -> None: - _write_todos(journal, "personal", "20240101", [{"text": "Option task"}]) - - positional = runner.invoke(app, ["list", "20240101", "--facet", "personal"]) - option = runner.invoke(app, ["list", "--day", "20240101", "--facet", "personal"]) - conflict = runner.invoke( - app, ["list", "20240101", "--day", "20240102", "--facet", "personal"] - ) - - assert positional.exit_code == 0 - assert option.exit_code == 0 - assert option.stdout == positional.stdout - assert conflict.exit_code == 1 - assert conflict.stderr == ( - "Error: conflicting day given as argument (20240101) and --day (20240102).\n" - ) - - -def test_add_success_and_call_action_log( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - _ensure_facet(journal, "personal") - - result = runner.invoke( - app, ["add", "Ship feature", "--day", "29991231", "--facet", "personal"] - ) - - assert result.exit_code == 0 - assert result.stdout == "1: [ ] Ship feature\n" - entries = _read_logs(journal, "personal", "29991231") - assert len(entries) == 1 - assert entries[0]["source"] == "call" - assert entries[0]["actor"] == "agent" - assert entries[0]["action"] == "todo_add" - assert entries[0]["params"] == {"line_number": 1, "text": "Ship feature"} - - -def test_add_appends_and_accepts_nudge( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - _write_todos(journal, "personal", FROZEN_DAY, [{"text": "First"}]) - - result = runner.invoke( - app, - [ - "add", - "Test nudge", - "--nudge", - "15:00", - "--day", - FROZEN_DAY, - "--facet", - "personal", - ], - ) - - assert result.exit_code == 0 - assert result.stdout == "1: [ ] First\n2: [ ] Test nudge (nudge 15:00)\n" - saved = _read_jsonl( - journal / "facets" / "personal" / "todos" / f"{FROZEN_DAY}.jsonl" - ) - assert saved[1]["nudge"] == "20260310T15:00" - - -def test_add_dedup_and_force(runner: CliRunner, journal: Path) -> None: - _write_todos(journal, "work", "20240102", [{"text": "Draft Q1 plan"}]) - _ensure_facet(journal, "personal") - - duplicate = runner.invoke( - app, - ["add", "Draft Q1 plan", "--day", "20240102", "--facet", "personal"], - ) - forced = runner.invoke( - app, - [ - "add", - "Draft Q1 plan", - "--day", - "20240102", - "--facet", - "personal", - "--force", - ], - ) - - assert duplicate.exit_code == 1 - assert duplicate.stdout == "" - assert duplicate.stderr == ( - "Duplicate detected for: Draft Q1 plan\n" - " [100%] work/20240102 line 1: Draft Q1 plan\n" - "Use --force to add anyway.\n" - ) - assert forced.exit_code == 0 - assert forced.stdout == "1: [ ] Draft Q1 plan\n" - - -def test_add_error_branches( - runner: CliRunner, - journal: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - _ensure_facet(journal, "personal") - - invalid_day = runner.invoke( - app, ["add", "Task", "--day", "bad", "--facet", "personal"] - ) - bad_nudge = runner.invoke( - app, - [ - "add", - "Task", - "--day", - "20240101", - "--facet", - "personal", - "--nudge", - "tomorrow nope", - ], - ) - empty = runner.invoke( - app, ["add", " ", "--day", "20240101", "--facet", "personal"] - ) - monkeypatch.delenv("SOL_DAY", raising=False) - monkeypatch.delenv("SOL_FACET", raising=False) - missing_day = runner.invoke(app, ["add", "Task", "--facet", "personal"]) - missing_facet = runner.invoke(app, ["add", "Task", "--day", "20240101"]) - - assert invalid_day.exit_code == 1 - assert invalid_day.stderr == "Error: invalid day format 'bad'\n" - assert bad_nudge.exit_code == 1 - assert bad_nudge.stderr == "Error: invalid nudge time after 'tomorrow': nope\n" - assert empty.exit_code == 1 - assert empty.stderr == "Error: todo text cannot be empty\n" - assert missing_day.exit_code == 1 - assert missing_day.stderr == ( - "Error: day is required (pass as argument or set SOL_DAY).\n" - ) - assert missing_facet.exit_code == 1 - assert missing_facet.stderr == ( - "Error: facet is required (pass as argument or set SOL_FACET).\n" - ) - - -@pytest.mark.parametrize("verb", ["done", "cancel"]) -def test_done_cancel_success_and_logs( - runner: CliRunner, journal: Path, verb: str -) -> None: - _write_todos(journal, "personal", "20240101", [{"text": "Buy milk"}]) - - result = runner.invoke(app, [verb, "1", "--day", "20240101", "--facet", "personal"]) - - assert result.exit_code == 0 - if verb == "done": - assert result.stdout == "1: [x] Buy milk\n" - action = "todo_done" - else: - assert result.stdout == "1: ~~[cancelled] Buy milk~~\n" - action = "todo_cancel" - entries = _read_logs(journal, "personal", "20240101") - assert entries[-1]["source"] == "call" - assert entries[-1]["actor"] == "agent" - assert entries[-1]["action"] == action - assert entries[-1]["params"] == {"line_number": 1, "text": "Buy milk"} - - -@pytest.mark.parametrize("verb", ["done", "cancel"]) -def test_done_cancel_errors( - runner: CliRunner, - journal: Path, - monkeypatch: pytest.MonkeyPatch, - verb: str, -) -> None: - _write_todos(journal, "personal", "20240101", [{"text": "Only one"}]) - bad_line = runner.invoke( - app, [verb, "5", "--day", "20240101", "--facet", "personal"] - ) - - missing = runner.invoke( - app, [verb, "1", "--day", "20240102", "--facet", "personal"] - ) - - def busy(cls, day: str, facet: str, modify_fn): - raise _lock_timeout(journal) - - monkeypatch.setattr(TodoChecklist, "locked_modify", classmethod(busy)) - locked = runner.invoke(app, [verb, "1", "--day", "20240101", "--facet", "personal"]) - - assert bad_line.exit_code == 1 - assert bad_line.stderr == "Error: line number 5 is out of range (1..1)\n" - assert missing.exit_code == 1 - assert missing.stderr == "Error: line number 1 is out of range (1..0)\n" - assert locked.exit_code == 1 - assert locked.stderr == "Error: todo list is busy, try again.\n" - - -def test_terminal_state_cli_errors_leave_jsonl_unchanged( - runner: CliRunner, journal: Path -) -> None: - completed_path = _write_todos( - journal, "personal", "20240101", [{"text": "Done", "completed": True}] - ) - completed_before = _read_jsonl(completed_path) - completed_bytes = completed_path.read_bytes() - - cancel_completed = runner.invoke( - app, ["cancel", "1", "--day", "20240101", "--facet", "personal"] - ) - - assert cancel_completed.exit_code == 1 - assert cancel_completed.stderr == "Error: Cannot cancel a completed todo.\n" - assert _read_jsonl(completed_path) == completed_before - assert completed_path.read_bytes() == completed_bytes - - cancelled_path = _write_todos( - journal, "personal", "20240102", [{"text": "Cancelled", "cancelled": True}] - ) - cancelled_before = _read_jsonl(cancelled_path) - cancelled_bytes = cancelled_path.read_bytes() - - done_cancelled = runner.invoke( - app, ["done", "1", "--day", "20240102", "--facet", "personal"] - ) - - assert done_cancelled.exit_code == 1 - assert done_cancelled.stderr == "Error: Cannot complete a cancelled todo.\n" - assert _read_jsonl(cancelled_path) == cancelled_before - assert cancelled_path.read_bytes() == cancelled_bytes - - -def test_done_rejects_move_tombstone_without_writing( - runner: CliRunner, journal: Path -) -> None: - source_path = _write_todos(journal, "work", "20240101", [{"text": "Move me"}]) - _ensure_facet(journal, "personal") - move_result = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240101", - "--from", - "work", - "--to", - "personal", - ], - ) - assert move_result.exit_code == 0 - tombstone_before = _read_jsonl(source_path) - tombstone_bytes = source_path.read_bytes() - - done_tombstone = runner.invoke( - app, ["done", "1", "--day", "20240101", "--facet", "work"] - ) - - assert done_tombstone.exit_code == 1 - assert done_tombstone.stderr == "Error: Cannot complete a cancelled todo.\n" - assert _read_jsonl(source_path) == tombstone_before - assert source_path.read_bytes() == tombstone_bytes - - -def test_move_success_nudge_consent_and_logs( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - _write_todos( - journal, - "work", - "20240101", - [{"text": "Ship feature", "nudge": "20240101T09:00", "created_at": 1234}], - ) - _write_todos(journal, "personal", "20240101", [{"text": "Existing"}]) - - result = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240101", - "--from", - "work", - "--to", - "personal", - "--consent", - ], - ) - - assert result.exit_code == 0 - assert result.stdout == "Moved todo 1 ('Ship feature') from 'work' to 'personal'.\n" - source = _read_jsonl(journal / "facets" / "work" / "todos" / "20240101.jsonl") - dest = _read_jsonl(journal / "facets" / "personal" / "todos" / "20240101.jsonl") - assert source[0]["cancelled"] is True - assert source[0]["cancelled_reason"] == "moved_to_facet" - assert source[0]["moved_to"] == "personal" - assert dest[0]["text"] == "Existing" - assert dest[1]["text"] == "Ship feature" - assert dest[1]["nudge"] == "20240101T09:00" - assert dest[1]["created_at"] == 1234 - - out_log = _read_logs(journal, "work", FROZEN_DAY)[0] - in_log = _read_logs(journal, "personal", FROZEN_DAY)[0] - assert out_log["source"] == "call" - assert out_log["actor"] == "agent" - assert out_log["action"] == "todo_move_out" - assert out_log["params"] == { - "moved_from": "work", - "moved_to": "personal", - "line_number": 1, - "text": "Ship feature", - "consent": True, - } - assert in_log["action"] == "todo_move_in" - assert in_log["params"] == { - "moved_from": "work", - "moved_to": "personal", - "line_number": 2, - "text": "Ship feature", - "consent": True, - } - - -@pytest.mark.parametrize( - ("argv", "stderr"), - [ - ( - ["move", "1", "--day", "bad", "--from", "work", "--to", "personal"], - "Error: Invalid day format 'bad', expected YYYYMMDD.\n", - ), - ( - ["move", "1", "--day", "20240101", "--from", "work", "--to", "work"], - "Error: source and destination facet are the same.\n", - ), - ], -) -def test_move_route_ordered_guards( - runner: CliRunner, journal: Path, argv: list[str], stderr: str -) -> None: - _write_todos(journal, "work", "20240101", [{"text": "Ship feature"}]) - _ensure_facet(journal, "personal") - - result = runner.invoke(app, argv) - - assert result.exit_code == 1 - assert result.stderr == stderr - - -def test_move_facet_error_precedes_bad_day(runner: CliRunner, journal: Path) -> None: - _ensure_facet(journal, "work") - - result = runner.invoke( - app, - [ - "move", - "1", - "--day", - "BADDAY", - "--from", - "nonexistent", - "--to", - "work", - ], - ) - - assert result.exit_code == 1 - assert result.stderr == "Error: Facet 'nonexistent' (--from) does not exist.\n" - - -@pytest.mark.parametrize( - ("from_facet", "to_facet", "stderr"), - [ - ("missing", "personal", "Error: Facet 'missing' (--from) does not exist.\n"), - ("work", "missing", "Error: Facet 'missing' (--to) does not exist.\n"), - ], -) -def test_move_missing_facet_errors( - runner: CliRunner, - journal: Path, - from_facet: str, - to_facet: str, - stderr: str, -) -> None: - _write_todos(journal, "work", "20240101", [{"text": "Ship feature"}]) - _ensure_facet(journal, "personal") - - result = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240101", - "--from", - from_facet, - "--to", - to_facet, - ], - ) - - assert result.exit_code == 1 - assert result.stderr == stderr - - -@pytest.mark.parametrize( - ("entries", "line", "stderr"), - [ - ( - [{"text": "Ship feature", "completed": True}], - "1", - "Error: Cannot move a completed todo.\n", - ), - ( - [{"text": "Ship feature", "cancelled": True}], - "1", - "Error: Cannot move an already cancelled todo.\n", - ), - ( - [{"text": "Ship feature"}], - "5", - "Error: line number 5 is out of range (1..1)\n", - ), - ], -) -def test_move_state_guards( - runner: CliRunner, - journal: Path, - entries: list[dict], - line: str, - stderr: str, -) -> None: - _write_todos(journal, "work", "20240101", entries) - _ensure_facet(journal, "personal") - - result = runner.invoke( - app, - [ - "move", - line, - "--day", - "20240101", - "--from", - "work", - "--to", - "personal", - ], - ) - - assert result.exit_code == 1 - assert result.stderr == stderr - - -def test_move_missing_source_partial_and_busy( - runner: CliRunner, journal: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - _ensure_facet(journal, "work") - _ensure_facet(journal, "personal") - missing = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240101", - "--from", - "work", - "--to", - "personal", - ], - ) - assert missing.exit_code == 1 - assert missing.stderr == "Error: No todos found for day 20240101 in facet 'work'.\n" - - _write_todos(journal, "work", "20240101", [{"text": "Partial"}]) - original_apply_cancel = TodoChecklist._apply_cancel - - def fail_cancel( - self: TodoChecklist, - item: TodoItem, - *, - cancelled_reason: str | None = None, - moved_to: str | None = None, - ): - raise RuntimeError("cancel failed") - - monkeypatch.setattr(TodoChecklist, "_apply_cancel", fail_cancel) - partial = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240101", - "--from", - "work", - "--to", - "personal", - ], - ) - assert partial.exit_code == 1 - assert partial.stderr == ( - "Warning: Item was appended to 'personal' but could not cancel source " - "in 'work'. Cancel it manually with: sol call todos cancel 1 --day " - "20240101 --facet work\n" - ) - - monkeypatch.setattr(TodoChecklist, "_apply_cancel", original_apply_cancel) - _write_todos(journal, "work", "20240102", [{"text": "Busy"}]) - _ensure_facet(journal, "personal") - - def busy_move(cls, *args, **kwargs): - raise _lock_timeout(journal) - - monkeypatch.setattr(TodoChecklist, "move_entry", classmethod(busy_move)) - busy = runner.invoke( - app, - [ - "move", - "1", - "--day", - "20240102", - "--from", - "work", - "--to", - "personal", - ], - ) - assert busy.exit_code == 1 - assert busy.stderr == "Error: todo list is busy, try again.\n" - - -def test_upcoming_success_filter_and_empty( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - _write_todos(journal, "work", "29991231", [{"text": "Future task"}]) - _write_todos(journal, "home", "20200101", [{"text": "Past task"}]) - - future = runner.invoke(app, ["upcoming", "--facet", "work"]) - empty = runner.invoke(app, ["upcoming", "--facet", "home"]) - - assert future.exit_code == 0 - assert future.stdout == "### Work: 29991231\n[ ] Future task\n" - assert empty.exit_code == 0 - assert empty.stdout == "No upcoming todos.\n" - - -def test_list_nudges_due_human_json_empty_and_readonly( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - work_path = _write_todos( - journal, - "work", - FROZEN_DAY, - [{"text": "Work ping", "nudge": "20260310T08:00"}], - ) - _write_todos( - journal, - "home", - FROZEN_DAY, - [{"text": "Home ping", "nudge": "20260310T09:00"}], - ) - before = work_path.read_text(encoding="utf-8") - - human = runner.invoke(app, ["list-nudges-due", "--facet", "work"]) - json_result = runner.invoke(app, ["list-nudges-due", "--json"]) - - assert human.exit_code == 0 - assert human.stdout == "1: [ ] Work ping (4h ago)\n" - assert json_result.exit_code == 0 - assert json.loads(json_result.stdout) == [ - { - "day": FROZEN_DAY, - "facet": "work", - "index": 1, - "text": "Work ping", - "nudge": "20260310T08:00", - "nudge_display": "4h ago", - }, - { - "day": FROZEN_DAY, - "facet": "home", - "index": 1, - "text": "Home ping", - "nudge": "20260310T09:00", - "nudge_display": "3h ago", - }, - ] - assert work_path.read_text(encoding="utf-8") == before - - empty_human = runner.invoke(app, ["list-nudges-due", "--facet", "personal"]) - empty_json = runner.invoke( - app, ["list-nudges-due", "--facet", "personal", "--json"] - ) - assert empty_human.exit_code == 0 - assert empty_human.stdout == "No nudges due.\n" - assert empty_json.exit_code == 0 - assert json.loads(empty_json.stdout) == [] - - -def test_list_nudges_due_multi_facet_human( - runner: CliRunner, journal: Path, frozen_clock: None -) -> None: - _write_todos( - journal, - "work", - FROZEN_DAY, - [{"text": "Work ping", "nudge": "20260310T08:00"}], - ) - _write_todos( - journal, - "home", - FROZEN_DAY, - [{"text": "Home ping", "nudge": "20260310T09:00"}], - ) - - result = runner.invoke(app, ["list-nudges-due"]) - - assert result.exit_code == 0 - assert result.stdout == ( - "## work\n1: [ ] Work ping (4h ago)\n\n## home\n1: [ ] Home ping (3h ago)\n\n" - ) - - -def test_dispatch_nudges_marks_subprocess_and_idempotency( - runner: CliRunner, - journal: Path, - monkeypatch: pytest.MonkeyPatch, - frozen_clock: None, -) -> None: - todo_path = _write_todos( - journal, - "work", - FROZEN_DAY, - [{"text": "Follow up", "nudge": "20260310T09:00"}], - ) - calls: list[tuple[list[str], dict]] = [] - - def fake_run(argv, **kwargs): - calls.append((argv, kwargs)) - return None - - monkeypatch.setattr(todos_call.subprocess, "run", fake_run) - - first = runner.invoke(app, ["dispatch-nudges", "--facet", "work"]) - second = runner.invoke(app, ["dispatch-nudges", "--facet", "work"]) - - assert first.exit_code == 0 - assert first.stdout == "dispatched 1 nudge(s)\n" - assert second.exit_code == 0 - assert second.stdout == "dispatched 0 nudge(s)\n" - assert calls == [ - ( - [ - "sol", - "notify", - "Follow up", - "--title", - "Todo Reminder", - "--icon", - "โœ…", - "--app", - "todos", - "--facet", - "work", - "--action", - "/app/todos/20260310", - ], - {"check": False, "capture_output": True}, - ) - ] - assert _read_jsonl(todo_path)[0]["notified"] is True - - -def test_dispatch_nudges_nothing_due_and_busy_skip( - runner: CliRunner, - journal: Path, - monkeypatch: pytest.MonkeyPatch, - frozen_clock: None, -) -> None: - _write_todos(journal, "empty", FROZEN_DAY, []) - calls: list[tuple[list[str], dict]] = [] - monkeypatch.setattr( - todos_call.subprocess, - "run", - lambda argv, **kwargs: calls.append((argv, kwargs)), - ) - - nothing = runner.invoke(app, ["dispatch-nudges", "--facet", "empty"]) - assert nothing.exit_code == 0 - assert nothing.stdout == "dispatched 0 nudge(s)\n" - assert calls == [] - - _write_todos( - journal, - "work", - FROZEN_DAY, - [{"text": "Busy ping", "nudge": "20260310T08:00"}], - ) - _write_todos( - journal, - "home", - FROZEN_DAY, - [{"text": "Home ping", "nudge": "20260310T09:00"}], - ) - original = TodoChecklist.locked_modify - - def flaky(cls, day: str, facet: str, modify_fn): - if facet == "work": - raise _lock_timeout(journal) - return original(day, facet, modify_fn) - - monkeypatch.setattr(TodoChecklist, "locked_modify", classmethod(flaky)) - skip = runner.invoke(app, ["dispatch-nudges"]) - - assert skip.exit_code == 0 - assert skip.stdout == "dispatched 1 nudge(s)\n" - assert calls == [ - ( - [ - "sol", - "notify", - "Home ping", - "--title", - "Todo Reminder", - "--icon", - "โœ…", - "--app", - "todos", - "--facet", - "home", - "--action", - "/app/todos/20260310", - ], - {"check": False, "capture_output": True}, - ) - ] - work = _read_jsonl(journal / "facets" / "work" / "todos" / f"{FROZEN_DAY}.jsonl") - home = _read_jsonl(journal / "facets" / "home" / "todos" / f"{FROZEN_DAY}.jsonl") - assert "notified" not in work[0] - assert home[0]["notified"] is True diff --git a/tests/verify_api.py b/tests/verify_api.py index eee2aef3e..f03d374af 100644 --- a/tests/verify_api.py +++ b/tests/verify_api.py @@ -316,28 +316,6 @@ ENDPOINTS = [ "params": {}, "status": 200, }, - # apps/todos/routes.py - { - "app": "todos", - "name": "badge-count", - "path": "/app/todos/api/badge-count", - "params": {}, - "status": 200, - }, - { - "app": "todos", - "name": "nudges", - "path": "/app/todos/api/nudges", - "params": {}, - "status": 200, - }, - { - "app": "todos", - "name": "stats-month", - "path": "/app/todos/api/stats/202603", - "params": {}, - "status": 200, - }, # apps/tokens/routes.py { "app": "tokens", diff --git a/tests/verify_browser.py b/tests/verify_browser.py index ff4f1ab65..d5c9a6abc 100644 --- a/tests/verify_browser.py +++ b/tests/verify_browser.py @@ -137,15 +137,6 @@ SCENARIOS: list[dict[str, Any]] = [ {"do": "screenshot"}, ], }, - { - "app": "todos", - "name": "smoke", - "steps": [ - {"do": "navigate", "path": "/app/todos/20260304"}, - {"do": "wait", "ms": 1000}, - {"do": "screenshot"}, - ], - }, { "app": "tokens", "name": "smoke", @@ -324,16 +315,6 @@ SCENARIOS: list[dict[str, Any]] = [ {"do": "screenshot"}, ], }, - { - "app": "todos", - "name": "todo-states", - "steps": [ - {"do": "set_cookie", "name": "facet", "value": "work"}, - {"do": "navigate", "path": "/app/todos/20260304"}, - {"do": "wait", "ms": 1200}, - {"do": "screenshot"}, - ], - }, ]