personal memory agent
0
fork

Configure Feed

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

feat: add entities/todos/calendar move primitives

Adds `sol call entities move`, `sol call todos move`, and
`sol call calendar move` -- the lowest-level building blocks for
facet reorganization workflows.

- Todos and calendar events are soft-cancelled in the source facet
(with `cancelled_reason: "moved_to_facet"` and `moved_to` metadata)
and re-appended in the destination, preserving created_at and nudge.
Dest-first ordering ensures the source remains intact if the append
fails. Source-cancel failures emit an actionable warning.
- Entities are moved at the directory level with `shutil.move`. An
optional `--merge` flag deduplicates observations by (content,
observed_at) and keeps the destination entity.json.
- All three commands require `--consent` for audit logging, validate
both `--from` and `--to` facets, and log to the facet action log.
- `TodoItem` and `CalendarEvent` gain `cancelled_reason` and `moved_to`
fields, surfaced in both JSONL storage and `as_dict()`.

+931 -10
+132
apps/calendar/call.py
··· 28 28 return True 29 29 30 30 31 + def _validate_facet_or_exit(facet: str, label: str) -> None: 32 + """Exit if the facet directory does not exist.""" 33 + from think.utils import get_journal 34 + 35 + facet_path = Path(get_journal()) / "facets" / facet 36 + if not facet_path.is_dir(): 37 + typer.echo( 38 + f"Error: Facet '{facet}' ({label}) does not exist.", 39 + err=True, 40 + ) 41 + raise typer.Exit(1) 42 + 43 + 31 44 @app.command("create") 32 45 def create_event( 33 46 title: str = typer.Argument(help="Event title."), ··· 287 300 except IndexError as exc: 288 301 typer.echo(f"Error: {exc}", err=True) 289 302 raise typer.Exit(1) 303 + 304 + 305 + @app.command("move") 306 + def move_event( 307 + line_number: int = typer.Argument( 308 + help="Line number of the event to move (1-indexed)." 309 + ), 310 + day: str = typer.Option(..., "--day", help="Day in YYYYMMDD format."), 311 + from_facet: str = typer.Option(..., "--from", help="Source facet."), 312 + to_facet: str = typer.Option(..., "--to", help="Destination facet."), 313 + consent: bool = typer.Option( 314 + False, 315 + "--consent", 316 + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", 317 + ), 318 + ) -> None: 319 + """Move an open calendar event from one facet to another.""" 320 + _validate_facet_or_exit(from_facet, "--from") 321 + _validate_facet_or_exit(to_facet, "--to") 322 + 323 + try: 324 + datetime.strptime(day, "%Y%m%d") 325 + except ValueError: 326 + typer.echo( 327 + f"Error: Invalid day format '{day}', expected YYYYMMDD.", 328 + err=True, 329 + ) 330 + raise typer.Exit(1) 331 + 332 + try: 333 + source_day = event.EventDay.load(day, from_facet) 334 + if not source_day.exists: 335 + raise FileNotFoundError() 336 + event.validate_line_number(line_number, len(source_day.items)) 337 + item = source_day.items[line_number - 1] 338 + if item.cancelled: 339 + raise event.CalendarEventError("Cannot move an already cancelled event.") 340 + except FileNotFoundError: 341 + typer.echo( 342 + f"Error: No events found for day {day} in facet '{from_facet}'.", 343 + err=True, 344 + ) 345 + raise typer.Exit(1) 346 + except IndexError as exc: 347 + typer.echo(f"Error: {exc}", err=True) 348 + raise typer.Exit(1) 349 + except event.CalendarEventError as exc: 350 + typer.echo(f"Error: {exc}", err=True) 351 + raise typer.Exit(1) 352 + 353 + try: 354 + 355 + def _append_dest( 356 + day_events: event.EventDay, 357 + ) -> tuple[event.EventDay, event.CalendarEvent]: 358 + new_item = day_events.append_event( 359 + item.title, 360 + item.start, 361 + item.end, 362 + item.summary, 363 + item.participants, 364 + created_at=item.created_at, 365 + ) 366 + return day_events, new_item 367 + 368 + _, new_item = event.EventDay.locked_modify(day, to_facet, _append_dest) 369 + except Exception as exc: 370 + typer.echo( 371 + f"Error: Failed to append to destination facet '{to_facet}': {exc}. Source event is unchanged.", 372 + err=True, 373 + ) 374 + raise typer.Exit(1) 375 + 376 + try: 377 + 378 + def _cancel_source( 379 + day_events: event.EventDay, 380 + ) -> tuple[event.EventDay, event.CalendarEvent]: 381 + event.validate_line_number(line_number, len(day_events.items)) 382 + current_item = day_events.items[line_number - 1] 383 + if current_item.cancelled: 384 + raise event.CalendarEventError( 385 + "Cannot move an already cancelled event." 386 + ) 387 + cancelled_item = day_events.cancel_event( 388 + line_number, 389 + cancelled_reason="moved_to_facet", 390 + moved_to=to_facet, 391 + ) 392 + return day_events, cancelled_item 393 + 394 + _, item = event.EventDay.locked_modify(day, from_facet, _cancel_source) 395 + except (FileNotFoundError, IndexError, event.CalendarEventError): 396 + typer.echo( 397 + f"Warning: Item was appended to '{to_facet}' but could not cancel source in '{from_facet}'. Cancel it manually with: sol call calendar cancel {line_number} --day {day} --facet {from_facet}", 398 + err=True, 399 + ) 400 + raise typer.Exit(1) 401 + 402 + params_out: dict[str, object] = { 403 + "moved_from": from_facet, 404 + "moved_to": to_facet, 405 + "line_number": line_number, 406 + "title": item.title, 407 + } 408 + params_in: dict[str, object] = { 409 + "moved_from": from_facet, 410 + "moved_to": to_facet, 411 + "line_number": new_item.index, 412 + "title": new_item.title, 413 + } 414 + if consent: 415 + params_out["consent"] = True 416 + params_in["consent"] = True 417 + log_call_action(facet=from_facet, action="calendar_move_out", params=params_out) 418 + log_call_action(facet=to_facet, action="calendar_move_in", params=params_in) 419 + typer.echo( 420 + f"Moved event {line_number} ('{item.title}') from '{from_facet}' to '{to_facet}'." 421 + )
+28 -5
apps/calendar/event.py
··· 56 56 summary: str | None 57 57 participants: list[str] | None 58 58 cancelled: bool 59 + cancelled_reason: str | None = None 60 + moved_to: str | None = None 59 61 created_at: int | None = None 60 62 updated_at: int | None = None 61 63 62 64 def as_dict(self) -> dict[str, object]: 63 65 """Return the item as a JSON-serializable dictionary.""" 64 - return { 66 + data: dict[str, object] = { 65 67 "index": self.index, 66 68 "title": self.title, 67 69 "start": self.start, ··· 72 74 "created_at": self.created_at, 73 75 "updated_at": self.updated_at, 74 76 } 77 + if self.cancelled_reason is not None: 78 + data["cancelled_reason"] = self.cancelled_reason 79 + if self.moved_to is not None: 80 + data["moved_to"] = self.moved_to 81 + return data 75 82 76 83 def to_jsonl(self) -> dict[str, Any]: 77 84 """Return the event as a sparse JSONL-compatible dictionary for storage.""" ··· 84 91 data["participants"] = self.participants 85 92 if self.cancelled: 86 93 data["cancelled"] = True 94 + if self.cancelled_reason is not None: 95 + data["cancelled_reason"] = self.cancelled_reason 96 + if self.moved_to is not None: 97 + data["moved_to"] = self.moved_to 87 98 if self.created_at is not None: 88 99 data["created_at"] = self.created_at 89 100 if self.updated_at is not None: ··· 113 124 summary=summary, 114 125 participants=participants, 115 126 cancelled=bool(data.get("cancelled", False)), 127 + cancelled_reason=data.get("cancelled_reason"), 128 + moved_to=data.get("moved_to"), 116 129 created_at=data.get("created_at"), 117 130 updated_at=data.get("updated_at"), 118 131 ) ··· 244 257 end: str | None = None, 245 258 summary: str | None = None, 246 259 participants: list[str] | None = None, 260 + created_at: int | None = None, 247 261 ) -> CalendarEvent: 248 262 """Append a new event entry.""" 249 263 clean_title = self._validated_title(title) ··· 253 267 if end < start: 254 268 raise ValueError("end time must be greater than or equal to start time") 255 269 256 - now = now_ms() 270 + ts = created_at if created_at is not None else now_ms() 257 271 item = CalendarEvent( 258 272 index=len(self.items) + 1, 259 273 title=clean_title, ··· 262 276 summary=summary, 263 277 participants=participants, 264 278 cancelled=False, 265 - created_at=now, 266 - updated_at=now, 279 + created_at=ts, 280 + updated_at=ts, 267 281 ) 268 282 269 283 self.items.append(item) 270 284 self.save() 271 285 return item 272 286 273 - def cancel_event(self, line_number: int) -> CalendarEvent: 287 + def cancel_event( 288 + self, 289 + line_number: int, 290 + cancelled_reason: str | None = None, 291 + moved_to: str | None = None, 292 + ) -> CalendarEvent: 274 293 """Cancel an event entry (soft delete).""" 275 294 _, item = self._get_item(line_number) 276 295 item.cancelled = True 296 + if cancelled_reason is not None: 297 + item.cancelled_reason = cancelled_reason 298 + if moved_to is not None: 299 + item.moved_to = moved_to 277 300 item.updated_at = now_ms() 278 301 self.save() 279 302 return item
+33
apps/calendar/tests/conftest.py
··· 56 56 return journal, facet 57 57 58 58 return _create 59 + 60 + 61 + @pytest.fixture 62 + def move_env(tmp_path, monkeypatch): 63 + """Create a two-facet environment for move tests.""" 64 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 65 + 66 + def _create( 67 + entries: list[dict] | None = None, 68 + day: str = "20240101", 69 + src_facet: str = "work", 70 + dst_facet: str = "personal", 71 + ): 72 + for facet in [src_facet, dst_facet]: 73 + facet_dir = tmp_path / "facets" / facet 74 + facet_dir.mkdir(parents=True, exist_ok=True) 75 + (facet_dir / "facet.json").write_text( 76 + json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 77 + encoding="utf-8", 78 + ) 79 + 80 + calendar_dir = tmp_path / "facets" / src_facet / "calendar" 81 + calendar_dir.mkdir(parents=True, exist_ok=True) 82 + calendar_path = calendar_dir / f"{day}.jsonl" 83 + if entries: 84 + lines = [] 85 + for entry in entries: 86 + lines.append(json.dumps(entry, ensure_ascii=False)) 87 + calendar_path.write_text("\n".join(lines) + "\n", encoding="utf-8") 88 + 89 + return tmp_path, src_facet, dst_facet 90 + 91 + return _create
+121
apps/calendar/tests/test_call.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 + import json 9 + 8 10 from typer.testing import CliRunner 9 11 10 12 from think.call import call_app ··· 338 340 339 341 assert result.exit_code == 1 340 342 assert "out of range" in result.output 343 + 344 + 345 + class TestCalendarMove: 346 + """Tests for ``sol call calendar move`` command.""" 347 + 348 + def test_move_event(self, move_env): 349 + journal, src_facet, dst_facet = move_env( 350 + [ 351 + { 352 + "title": "Standup", 353 + "start": "09:00", 354 + "end": "09:30", 355 + "summary": "Daily sync", 356 + "participants": ["Alice"], 357 + "created_at": 1000, 358 + "updated_at": 1000, 359 + } 360 + ] 361 + ) 362 + 363 + result = runner.invoke( 364 + call_app, 365 + [ 366 + "calendar", 367 + "move", 368 + "1", 369 + "--day", 370 + "20240101", 371 + "--from", 372 + src_facet, 373 + "--to", 374 + dst_facet, 375 + ], 376 + ) 377 + 378 + assert result.exit_code == 0 379 + source_items = [ 380 + json.loads(line) 381 + for line in (journal / "facets" / src_facet / "calendar" / "20240101.jsonl") 382 + .read_text(encoding="utf-8") 383 + .splitlines() 384 + ] 385 + dest_items = [ 386 + json.loads(line) 387 + for line in (journal / "facets" / dst_facet / "calendar" / "20240101.jsonl") 388 + .read_text(encoding="utf-8") 389 + .splitlines() 390 + ] 391 + assert source_items[0]["cancelled"] is True 392 + assert source_items[0]["cancelled_reason"] == "moved_to_facet" 393 + assert source_items[0]["moved_to"] == dst_facet 394 + assert dest_items[0]["title"] == "Standup" 395 + assert dest_items[0]["participants"] == ["Alice"] 396 + assert dest_items[0]["created_at"] == source_items[0]["created_at"] 397 + 398 + def test_move_already_cancelled(self, move_env): 399 + _, src_facet, dst_facet = move_env( 400 + [{"title": "Standup", "start": "09:00", "cancelled": True}] 401 + ) 402 + 403 + result = runner.invoke( 404 + call_app, 405 + [ 406 + "calendar", 407 + "move", 408 + "1", 409 + "--day", 410 + "20240101", 411 + "--from", 412 + src_facet, 413 + "--to", 414 + dst_facet, 415 + ], 416 + ) 417 + 418 + assert result.exit_code == 1 419 + assert "already cancelled" in result.output 420 + 421 + def test_move_invalid_line_number(self, move_env): 422 + _, src_facet, dst_facet = move_env([{"title": "Standup", "start": "09:00"}]) 423 + 424 + result = runner.invoke( 425 + call_app, 426 + [ 427 + "calendar", 428 + "move", 429 + "5", 430 + "--day", 431 + "20240101", 432 + "--from", 433 + src_facet, 434 + "--to", 435 + dst_facet, 436 + ], 437 + ) 438 + 439 + assert result.exit_code == 1 440 + assert "out of range" in result.output 441 + 442 + def test_move_missing_facet(self, move_env): 443 + move_env([{"title": "Standup", "start": "09:00"}], dst_facet="personal") 444 + 445 + result = runner.invoke( 446 + call_app, 447 + [ 448 + "calendar", 449 + "move", 450 + "1", 451 + "--day", 452 + "20240101", 453 + "--from", 454 + "work", 455 + "--to", 456 + "missing", 457 + ], 458 + ) 459 + 460 + assert result.exit_code == 1 461 + assert "does not exist" in result.output 341 462 342 463 343 464 class TestCalendarEnvResolution:
+90 -2
apps/entities/call.py
··· 7 7 """ 8 8 9 9 import re 10 + import shutil 11 + from pathlib import Path 10 12 11 13 import typer 12 14 ··· 18 20 ) 19 21 from think.entities.loading import load_entities 20 22 from think.entities.matching import resolve_entity, validate_aka_uniqueness 21 - from think.entities.observations import add_observation, load_observations 23 + from think.entities.observations import ( 24 + add_observation, 25 + load_observations, 26 + save_observations, 27 + ) 22 28 from think.entities.relationships import ( 29 + entity_memory_path, 23 30 load_facet_relationship, 24 31 save_facet_relationship, 25 32 ) ··· 33 40 get_entity_strength, 34 41 search_entities, 35 42 ) 36 - from think.utils import now_ms, resolve_sol_day, resolve_sol_facet 43 + from think.utils import get_journal, now_ms, resolve_sol_day, resolve_sol_facet 37 44 38 45 app = typer.Typer(help="Entity management.") 39 46 ··· 61 68 raise typer.Exit(1) 62 69 63 70 71 + def _validate_facet_or_exit(facet: str, label: str) -> None: 72 + """Exit if the facet directory does not exist.""" 73 + facet_path = Path(get_journal()) / "facets" / facet 74 + if not facet_path.is_dir(): 75 + typer.echo( 76 + f"Error: Facet '{facet}' ({label}) does not exist.", 77 + err=True, 78 + ) 79 + raise typer.Exit(1) 80 + 81 + 64 82 @app.command("list") 65 83 def list_entities( 66 84 facet: str | None = typer.Argument(None, help="Facet name (or set SOL_FACET)."), ··· 79 97 typer.echo(f"{len(entities)} {label} entities:") 80 98 for e in entities: 81 99 typer.echo(f" - {e.get('name')} ({e.get('type')}): {e.get('description', '')}") 100 + 101 + 102 + @app.command("move") 103 + def move_entity( 104 + entity: str = typer.Argument(help="Entity name or partial match."), 105 + from_facet: str = typer.Option(..., "--from", help="Source facet."), 106 + to_facet: str = typer.Option(..., "--to", help="Destination facet."), 107 + merge: bool = typer.Option( 108 + False, 109 + "--merge", 110 + help="Merge if entity already exists in destination.", 111 + ), 112 + consent: bool = typer.Option( 113 + False, 114 + "--consent", 115 + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", 116 + ), 117 + ) -> None: 118 + """Move an entity from one facet to another.""" 119 + _validate_facet_or_exit(from_facet, "--from") 120 + _validate_facet_or_exit(to_facet, "--to") 121 + 122 + resolved = _resolve_or_exit(from_facet, entity) 123 + entity_name = str(resolved.get("name", entity)) 124 + entity_id = entity_slug(entity_name) 125 + src_dir = entity_memory_path(from_facet, entity_name) 126 + dst_dir = entity_memory_path(to_facet, entity_name) 127 + 128 + if not src_dir.exists(): 129 + typer.echo("Error: Entity data directory not found in source facet.", err=True) 130 + raise typer.Exit(1) 131 + 132 + if dst_dir.exists() and not merge: 133 + typer.echo( 134 + "Error: Entity already exists in destination facet. Use --merge to merge.", 135 + err=True, 136 + ) 137 + raise typer.Exit(1) 138 + 139 + if dst_dir.exists(): 140 + src_relationship = load_facet_relationship(from_facet, entity_id) 141 + dst_relationship = load_facet_relationship(to_facet, entity_id) 142 + if src_relationship is not None and dst_relationship is None: 143 + save_facet_relationship(to_facet, entity_id, src_relationship) 144 + 145 + src_obs = load_observations(from_facet, entity_name) 146 + dst_obs = load_observations(to_facet, entity_name) 147 + existing_keys = {(o["content"], o.get("observed_at")) for o in dst_obs} 148 + merged = list(dst_obs) + [ 149 + o 150 + for o in src_obs 151 + if (o["content"], o.get("observed_at")) not in existing_keys 152 + ] 153 + save_observations(to_facet, entity_name, merged) 154 + shutil.rmtree(str(src_dir)) 155 + else: 156 + dst_dir.parent.mkdir(parents=True, exist_ok=True) 157 + shutil.move(str(src_dir), str(dst_dir)) 158 + 159 + params: dict[str, object] = { 160 + "entity": entity_name, 161 + "moved_from": from_facet, 162 + "moved_to": to_facet, 163 + } 164 + if merge: 165 + params["merge"] = True 166 + if consent: 167 + params["consent"] = True 168 + log_call_action(facet=from_facet, action="entity_move", params=params) 169 + typer.echo(f"Moved entity '{entity_name}' from '{from_facet}' to '{to_facet}'.") 82 170 83 171 84 172 @app.command("detect")
+47 -1
apps/entities/tests/conftest.py
··· 5 5 6 6 from __future__ import annotations 7 7 8 + import json 9 + 8 10 import pytest 9 11 10 - from think.entities.observations import add_observation 12 + from think.entities.observations import add_observation, save_observations 11 13 from think.entities.saving import save_entities 12 14 13 15 ··· 42 44 return tmp_path 43 45 44 46 return _create 47 + 48 + 49 + @pytest.fixture 50 + def entity_move_env(tmp_path, monkeypatch): 51 + """Create a two-facet environment for entity move tests.""" 52 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 53 + 54 + def _create( 55 + entity_name: str = "Alice Johnson", 56 + src_facet: str = "work", 57 + dst_facet: str = "personal", 58 + src_observations: list[dict] | None = None, 59 + dst_observations: list[dict] | None = None, 60 + create_dst_entity: bool = False, 61 + ): 62 + for facet in [src_facet, dst_facet]: 63 + facet_dir = tmp_path / "facets" / facet 64 + facet_dir.mkdir(parents=True, exist_ok=True) 65 + (facet_dir / "facet.json").write_text( 66 + json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 67 + encoding="utf-8", 68 + ) 69 + 70 + entity = { 71 + "type": "Person", 72 + "name": entity_name, 73 + "description": "Friend", 74 + "attached_at": 1000, 75 + "updated_at": 1000, 76 + } 77 + save_entities(src_facet, [entity], day=None) 78 + 79 + if src_observations: 80 + save_observations(src_facet, entity_name, src_observations) 81 + 82 + if create_dst_entity: 83 + save_entities(dst_facet, [entity], day=None) 84 + 85 + if dst_observations: 86 + save_observations(dst_facet, entity_name, dst_observations) 87 + 88 + return tmp_path, src_facet, dst_facet, entity_name 89 + 90 + return _create
+117
apps/entities/tests/test_call.py
··· 3 3 4 4 """Tests for entities CLI commands (sol call entities ...).""" 5 5 6 + import json 7 + 6 8 from typer.testing import CliRunner 7 9 8 10 from think.call import call_app 11 + from think.entities.core import entity_slug 9 12 10 13 runner = CliRunner() 11 14 ··· 269 272 270 273 assert result.exit_code == 1 271 274 assert "not found" in result.output 275 + 276 + 277 + class TestEntitiesMove: 278 + def test_move_entity(self, entity_move_env): 279 + journal, src_facet, dst_facet, entity_name = entity_move_env() 280 + slug = entity_slug(entity_name) 281 + 282 + result = runner.invoke( 283 + call_app, 284 + [ 285 + "entities", 286 + "move", 287 + entity_name, 288 + "--from", 289 + src_facet, 290 + "--to", 291 + dst_facet, 292 + ], 293 + ) 294 + 295 + assert result.exit_code == 0 296 + src_dir = journal / "facets" / src_facet / "entities" / slug 297 + dst_dir = journal / "facets" / dst_facet / "entities" / slug 298 + assert not src_dir.exists() 299 + assert dst_dir.exists() 300 + 301 + def test_move_entity_already_exists_no_merge(self, entity_move_env): 302 + _, src_facet, dst_facet, entity_name = entity_move_env(create_dst_entity=True) 303 + 304 + result = runner.invoke( 305 + call_app, 306 + [ 307 + "entities", 308 + "move", 309 + entity_name, 310 + "--from", 311 + src_facet, 312 + "--to", 313 + dst_facet, 314 + ], 315 + ) 316 + 317 + assert result.exit_code == 1 318 + assert "Use --merge" in result.output 319 + 320 + def test_move_entity_merge(self, entity_move_env): 321 + journal, src_facet, dst_facet, entity_name = entity_move_env( 322 + src_observations=[ 323 + { 324 + "content": "Prefers async communication", 325 + "observed_at": 1000, 326 + "source_day": "20240101", 327 + }, 328 + {"content": "Uses Vim", "observed_at": 1001, "source_day": "20240102"}, 329 + ], 330 + dst_observations=[ 331 + { 332 + "content": "Prefers async communication", 333 + "observed_at": 1000, 334 + "source_day": "20240101", 335 + }, 336 + {"content": "Likes tea", "observed_at": 1002, "source_day": "20240103"}, 337 + ], 338 + create_dst_entity=True, 339 + ) 340 + slug = entity_slug(entity_name) 341 + 342 + result = runner.invoke( 343 + call_app, 344 + [ 345 + "entities", 346 + "move", 347 + entity_name, 348 + "--from", 349 + src_facet, 350 + "--to", 351 + dst_facet, 352 + "--merge", 353 + ], 354 + ) 355 + 356 + assert result.exit_code == 0 357 + src_dir = journal / "facets" / src_facet / "entities" / slug 358 + dst_obs_path = ( 359 + journal / "facets" / dst_facet / "entities" / slug / "observations.jsonl" 360 + ) 361 + observations = [ 362 + json.loads(line) 363 + for line in dst_obs_path.read_text(encoding="utf-8").splitlines() 364 + ] 365 + assert not src_dir.exists() 366 + assert len(observations) == 3 367 + 368 + def test_move_entity_not_found(self, entity_move_env): 369 + _, src_facet, dst_facet, _ = entity_move_env() 370 + 371 + result = runner.invoke( 372 + call_app, 373 + ["entities", "move", "Missing", "--from", src_facet, "--to", dst_facet], 374 + ) 375 + 376 + assert result.exit_code == 1 377 + assert "not found" in result.output 378 + 379 + def test_move_missing_facet(self, entity_move_env): 380 + _, src_facet, _, entity_name = entity_move_env() 381 + 382 + result = runner.invoke( 383 + call_app, 384 + ["entities", "move", entity_name, "--from", src_facet, "--to", "missing"], 385 + ) 386 + 387 + assert result.exit_code == 1 388 + assert "does not exist" in result.output 272 389 273 390 274 391 class TestEntitiesAka:
+134
apps/todos/call.py
··· 6 6 Auto-discovered by ``think.call`` and mounted as ``sol call todos ...``. 7 7 """ 8 8 9 + from pathlib import Path 10 + 9 11 import typer 10 12 11 13 from apps.todos import todo 12 14 from think.facets import log_call_action 15 + from think.utils import get_journal 13 16 14 17 app = typer.Typer(help="Todo checklist management.") 15 18 ··· 21 24 return False 22 25 typer.echo(checklist.display()) 23 26 return True 27 + 28 + 29 + def _validate_facet_or_exit(facet: str, label: str) -> None: 30 + """Exit if the facet directory does not exist.""" 31 + facet_path = Path(get_journal()) / "facets" / facet 32 + if not facet_path.is_dir(): 33 + typer.echo( 34 + f"Error: Facet '{facet}' ({label}) does not exist.", 35 + err=True, 36 + ) 37 + raise typer.Exit(1) 24 38 25 39 26 40 @app.command("list") ··· 222 236 except IndexError as exc: 223 237 typer.echo(f"Error: {exc}", err=True) 224 238 raise typer.Exit(1) 239 + 240 + 241 + @app.command("move") 242 + def move_todo( 243 + line_number: int = typer.Argument( 244 + help="Line number of the todo to move (1-indexed)." 245 + ), 246 + day: str = typer.Option(..., "--day", help="Day in YYYYMMDD format."), 247 + from_facet: str = typer.Option(..., "--from", help="Source facet."), 248 + to_facet: str = typer.Option(..., "--to", help="Destination facet."), 249 + consent: bool = typer.Option( 250 + False, 251 + "--consent", 252 + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", 253 + ), 254 + ) -> None: 255 + """Move an open todo from one facet to another.""" 256 + from datetime import datetime 257 + 258 + _validate_facet_or_exit(from_facet, "--from") 259 + _validate_facet_or_exit(to_facet, "--to") 260 + 261 + try: 262 + datetime.strptime(day, "%Y%m%d") 263 + except ValueError: 264 + typer.echo( 265 + f"Error: Invalid day format '{day}', expected YYYYMMDD.", 266 + err=True, 267 + ) 268 + raise typer.Exit(1) 269 + 270 + try: 271 + source_checklist = todo.TodoChecklist.load(day, from_facet) 272 + if not source_checklist.exists: 273 + raise FileNotFoundError() 274 + todo.validate_line_number(line_number, len(source_checklist.items)) 275 + item = source_checklist.items[line_number - 1] 276 + if item.completed: 277 + raise todo.TodoError("Cannot move a completed todo.") 278 + if item.cancelled: 279 + raise todo.TodoError("Cannot move an already cancelled todo.") 280 + except FileNotFoundError: 281 + typer.echo( 282 + f"Error: No todos found for day {day} in facet '{from_facet}'.", 283 + err=True, 284 + ) 285 + raise typer.Exit(1) 286 + except IndexError as exc: 287 + typer.echo(f"Error: {exc}", err=True) 288 + raise typer.Exit(1) 289 + except todo.TodoError as exc: 290 + typer.echo(f"Error: {exc}", err=True) 291 + raise typer.Exit(1) 292 + 293 + try: 294 + 295 + def _append_dest( 296 + checklist: todo.TodoChecklist, 297 + ) -> tuple[todo.TodoChecklist, todo.TodoItem]: 298 + new_item = checklist.append_entry( 299 + item.text, 300 + item.nudge, 301 + created_at=item.created_at, 302 + ) 303 + return checklist, new_item 304 + 305 + _, new_item = todo.TodoChecklist.locked_modify(day, to_facet, _append_dest) 306 + except Exception as exc: 307 + typer.echo( 308 + f"Error: Failed to append to destination facet '{to_facet}': {exc}. Source todo is unchanged.", 309 + err=True, 310 + ) 311 + raise typer.Exit(1) 312 + 313 + try: 314 + 315 + def _cancel_source( 316 + checklist: todo.TodoChecklist, 317 + ) -> tuple[todo.TodoChecklist, todo.TodoItem]: 318 + todo.validate_line_number(line_number, len(checklist.items)) 319 + current_item = checklist.items[line_number - 1] 320 + if current_item.completed: 321 + raise todo.TodoError("Cannot move a completed todo.") 322 + if current_item.cancelled: 323 + raise todo.TodoError("Cannot move an already cancelled todo.") 324 + cancelled_item = checklist.cancel_entry( 325 + line_number, 326 + cancelled_reason="moved_to_facet", 327 + moved_to=to_facet, 328 + ) 329 + return checklist, cancelled_item 330 + 331 + _, item = todo.TodoChecklist.locked_modify(day, from_facet, _cancel_source) 332 + except (FileNotFoundError, IndexError, todo.TodoError): 333 + typer.echo( 334 + f"Warning: Item was appended to '{to_facet}' but could not cancel source in '{from_facet}'. Cancel it manually with: sol call todos cancel {line_number} --day {day} --facet {from_facet}", 335 + err=True, 336 + ) 337 + raise typer.Exit(1) 338 + 339 + params_out: dict[str, object] = { 340 + "moved_from": from_facet, 341 + "moved_to": to_facet, 342 + "line_number": line_number, 343 + "text": item.text, 344 + } 345 + params_in: dict[str, object] = { 346 + "moved_from": from_facet, 347 + "moved_to": to_facet, 348 + "line_number": new_item.index, 349 + "text": new_item.text, 350 + } 351 + if consent: 352 + params_out["consent"] = True 353 + params_in["consent"] = True 354 + log_call_action(facet=from_facet, action="todo_move_out", params=params_out) 355 + log_call_action(facet=to_facet, action="todo_move_in", params=params_in) 356 + typer.echo( 357 + f"Moved todo {line_number} ('{item.text}') from '{from_facet}' to '{to_facet}'." 358 + ) 225 359 226 360 227 361 @app.command("upcoming")
+45
apps/todos/tests/conftest.py
··· 78 78 return journal, facet 79 79 80 80 return _create 81 + 82 + 83 + @pytest.fixture 84 + def move_env(tmp_path, monkeypatch): 85 + """Create a two-facet environment for move tests.""" 86 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 87 + 88 + def _create( 89 + entries: list[dict] | None = None, 90 + day: str = "20240101", 91 + src_facet: str = "work", 92 + dst_facet: str = "personal", 93 + ): 94 + for facet in [src_facet, dst_facet]: 95 + facet_dir = tmp_path / "facets" / facet 96 + facet_dir.mkdir(parents=True, exist_ok=True) 97 + (facet_dir / "facet.json").write_text( 98 + json.dumps({"title": f"Test {facet}", "description": "Test facet"}), 99 + encoding="utf-8", 100 + ) 101 + 102 + todos_dir = tmp_path / "facets" / src_facet / "todos" 103 + todos_dir.mkdir(parents=True, exist_ok=True) 104 + todo_path = todos_dir / f"{day}.jsonl" 105 + if entries: 106 + now_ms = int(datetime.now().timestamp() * 1000) 107 + lines = [] 108 + for entry in entries: 109 + data = { 110 + "text": entry["text"], 111 + "created_at": entry.get("created_at", now_ms), 112 + "updated_at": entry.get("updated_at", now_ms), 113 + } 114 + if entry.get("cancelled"): 115 + data["cancelled"] = True 116 + if entry.get("completed"): 117 + data["completed"] = True 118 + if entry.get("nudge"): 119 + data["nudge"] = entry["nudge"] 120 + lines.append(json.dumps(data, ensure_ascii=False)) 121 + todo_path.write_text("\n".join(lines) + "\n", encoding="utf-8") 122 + 123 + return tmp_path, src_facet, dst_facet 124 + 125 + return _create
+160
apps/todos/tests/test_call.py
··· 3 3 4 4 """Tests for todos CLI commands (sol call todos ...).""" 5 5 6 + import json 7 + 6 8 from typer.testing import CliRunner 7 9 8 10 from think.call import call_app ··· 195 197 result = runner.invoke(call_app, ["todos", "upcoming"]) 196 198 assert result.exit_code == 0 197 199 assert "No upcoming todos" in result.output 200 + 201 + 202 + class TestTodosMove: 203 + """Tests for 'sol call todos move' command.""" 204 + 205 + def test_move_todo(self, move_env): 206 + journal, src_facet, dst_facet = move_env([{"text": "Ship feature"}]) 207 + 208 + result = runner.invoke( 209 + call_app, 210 + [ 211 + "todos", 212 + "move", 213 + "1", 214 + "--day", 215 + "20240101", 216 + "--from", 217 + src_facet, 218 + "--to", 219 + dst_facet, 220 + ], 221 + ) 222 + 223 + assert result.exit_code == 0 224 + source_items = [ 225 + json.loads(line) 226 + for line in (journal / "facets" / src_facet / "todos" / "20240101.jsonl") 227 + .read_text(encoding="utf-8") 228 + .splitlines() 229 + ] 230 + dest_items = [ 231 + json.loads(line) 232 + for line in (journal / "facets" / dst_facet / "todos" / "20240101.jsonl") 233 + .read_text(encoding="utf-8") 234 + .splitlines() 235 + ] 236 + assert source_items[0]["cancelled"] is True 237 + assert source_items[0]["cancelled_reason"] == "moved_to_facet" 238 + assert source_items[0]["moved_to"] == dst_facet 239 + assert dest_items[0]["text"] == "Ship feature" 240 + assert dest_items[0]["created_at"] == source_items[0]["created_at"] 241 + 242 + def test_move_todo_with_nudge(self, move_env): 243 + journal, src_facet, dst_facet = move_env( 244 + [{"text": "Call Alice", "nudge": "20240101T09:00"}] 245 + ) 246 + 247 + result = runner.invoke( 248 + call_app, 249 + [ 250 + "todos", 251 + "move", 252 + "1", 253 + "--day", 254 + "20240101", 255 + "--from", 256 + src_facet, 257 + "--to", 258 + dst_facet, 259 + ], 260 + ) 261 + 262 + assert result.exit_code == 0 263 + dest_items = [ 264 + json.loads(line) 265 + for line in (journal / "facets" / dst_facet / "todos" / "20240101.jsonl") 266 + .read_text(encoding="utf-8") 267 + .splitlines() 268 + ] 269 + assert dest_items[0]["nudge"] == "20240101T09:00" 270 + 271 + def test_move_already_cancelled(self, move_env): 272 + _, src_facet, dst_facet = move_env( 273 + [{"text": "Ship feature", "cancelled": True}] 274 + ) 275 + 276 + result = runner.invoke( 277 + call_app, 278 + [ 279 + "todos", 280 + "move", 281 + "1", 282 + "--day", 283 + "20240101", 284 + "--from", 285 + src_facet, 286 + "--to", 287 + dst_facet, 288 + ], 289 + ) 290 + 291 + assert result.exit_code == 1 292 + assert "already cancelled" in result.output 293 + 294 + def test_move_already_completed(self, move_env): 295 + _, src_facet, dst_facet = move_env( 296 + [{"text": "Ship feature", "completed": True}] 297 + ) 298 + 299 + result = runner.invoke( 300 + call_app, 301 + [ 302 + "todos", 303 + "move", 304 + "1", 305 + "--day", 306 + "20240101", 307 + "--from", 308 + src_facet, 309 + "--to", 310 + dst_facet, 311 + ], 312 + ) 313 + 314 + assert result.exit_code == 1 315 + assert "completed todo" in result.output 316 + 317 + def test_move_invalid_line_number(self, move_env): 318 + _, src_facet, dst_facet = move_env([{"text": "Ship feature"}]) 319 + 320 + result = runner.invoke( 321 + call_app, 322 + [ 323 + "todos", 324 + "move", 325 + "5", 326 + "--day", 327 + "20240101", 328 + "--from", 329 + src_facet, 330 + "--to", 331 + dst_facet, 332 + ], 333 + ) 334 + 335 + assert result.exit_code == 1 336 + assert "out of range" in result.output 337 + 338 + def test_move_missing_facet(self, move_env): 339 + move_env([{"text": "Ship feature"}], dst_facet="personal") 340 + 341 + result = runner.invoke( 342 + call_app, 343 + [ 344 + "todos", 345 + "move", 346 + "1", 347 + "--day", 348 + "20240101", 349 + "--from", 350 + "work", 351 + "--to", 352 + "missing", 353 + ], 354 + ) 355 + 356 + assert result.exit_code == 1 357 + assert "does not exist" in result.output 198 358 199 359 200 360 class TestSolEnvResolution:
+24 -2
apps/todos/todo.py
··· 162 162 nudge: str | None 163 163 completed: bool 164 164 cancelled: bool 165 + cancelled_reason: str | None = None 166 + moved_to: str | None = None 165 167 created_at: int | None = None 166 168 updated_at: int | None = None 167 169 notified: bool = False 168 170 169 171 def as_dict(self) -> dict[str, object]: 170 172 """Return the item as a JSON-serializable dictionary.""" 171 - return { 173 + data: dict[str, object] = { 172 174 "index": self.index, 173 175 "text": self.text, 174 176 "nudge": self.nudge, ··· 178 180 "updated_at": self.updated_at, 179 181 "notified": self.notified, 180 182 } 183 + if self.cancelled_reason is not None: 184 + data["cancelled_reason"] = self.cancelled_reason 185 + if self.moved_to is not None: 186 + data["moved_to"] = self.moved_to 187 + return data 181 188 182 189 def to_jsonl(self) -> dict[str, Any]: 183 190 """Return the item as a JSONL-compatible dictionary for storage.""" ··· 188 195 data["completed"] = True 189 196 if self.cancelled: 190 197 data["cancelled"] = True 198 + if self.cancelled_reason is not None: 199 + data["cancelled_reason"] = self.cancelled_reason 200 + if self.moved_to is not None: 201 + data["moved_to"] = self.moved_to 191 202 if self.notified: 192 203 data["notified"] = True 193 204 if self.created_at is not None: ··· 210 221 nudge=nudge, 211 222 completed=data.get("completed", False), 212 223 cancelled=data.get("cancelled", False), 224 + cancelled_reason=data.get("cancelled_reason"), 225 + moved_to=data.get("moved_to"), 213 226 created_at=data.get("created_at"), 214 227 updated_at=data.get("updated_at"), 215 228 notified=data.get("notified", False), ··· 403 416 self.save() 404 417 return item 405 418 406 - def cancel_entry(self, line_number: int) -> TodoItem: 419 + def cancel_entry( 420 + self, 421 + line_number: int, 422 + cancelled_reason: str | None = None, 423 + moved_to: str | None = None, 424 + ) -> TodoItem: 407 425 """Cancel a todo entry (soft delete). 408 426 409 427 Args: ··· 415 433 _, item = self._get_item(line_number) 416 434 417 435 item.cancelled = True 436 + if cancelled_reason is not None: 437 + item.cancelled_reason = cancelled_reason 438 + if moved_to is not None: 439 + item.moved_to = moved_to 418 440 item.updated_at = now_ms() 419 441 self.save() 420 442 return item