personal memory agent
0
fork

Configure Feed

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

think/surfaces: address audit feedback - tighten ledger review coverage

+222 -6
+211 -2
tests/test_surfaces_ledger.py
··· 5 5 from datetime import UTC, datetime 6 6 7 7 import pytest 8 + from typer.testing import CliRunner 8 9 9 10 _DAY_MS = 86_400_000 11 + _RUNNER = CliRunner() 10 12 11 13 12 14 def _utc_ms(value: str) -> int: 13 15 return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) 14 16 15 17 16 - def _minimal_facet_tree(tmp_path, facets=("work",)) -> None: 18 + def _minimal_facet_tree(tmp_path, facets=("work",), *, muted_facets=()) -> None: 17 19 for facet in facets: 18 20 facet_dir = tmp_path / "facets" / facet 19 21 facet_dir.mkdir(parents=True, exist_ok=True) ··· 25 27 "description": "", 26 28 "color": "", 27 29 "emoji": "", 28 - "muted": False, 30 + "muted": facet in set(muted_facets), 29 31 } 30 32 ), 31 33 encoding="utf-8", ··· 349 351 assert dropped.state == "dropped" 350 352 351 353 354 + def test_close_with_new_as_state_appends_and_first_close_wins(tmp_path, monkeypatch): 355 + from think.activities import load_activity_records 356 + from think.surfaces import ledger as ledger_surface 357 + 358 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 359 + _minimal_facet_tree(tmp_path) 360 + _write_story_activity( 361 + "work", 362 + "20260410", 363 + "meeting_090000_300", 364 + _utc_ms("2026-04-10T09:00:00Z"), 365 + commitments=[_commitment()], 366 + ) 367 + 368 + item = ledger_surface.list(state="open")[0] 369 + ledger_surface.close(item.id, note="done", as_state="closed") 370 + dropped = ledger_surface.close(item.id, note="actually dropped", as_state="dropped") 371 + 372 + assert dropped.state == "closed" 373 + record = load_activity_records("work", "20260410", include_hidden=True)[0] 374 + closes = [ 375 + edit["ledger_close"]["as_state"] 376 + for edit in record["edits"] 377 + if edit.get("fields") == ["ledger_close"] 378 + ] 379 + assert closes == ["closed", "dropped"] 380 + 381 + 352 382 def test_decisions_dedup(tmp_path, monkeypatch): 353 383 from think.surfaces import ledger as ledger_surface 354 384 ··· 423 453 assert any(item.action == "draft status update" for item in open_items) 424 454 425 455 456 + def test_missing_counterparty_id_pairing_falls_back_to_text(tmp_path, monkeypatch): 457 + from think.surfaces import ledger as ledger_surface 458 + 459 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 460 + _minimal_facet_tree(tmp_path, facets=("work", "personal")) 461 + _write_story_activity( 462 + "work", 463 + "20260410", 464 + "meeting_090000_300", 465 + _utc_ms("2026-04-10T09:00:00Z"), 466 + commitments=[ 467 + _commitment( 468 + counterparty="Finance Team", 469 + counterparty_entity_id=None, 470 + owner_entity_id=None, 471 + ) 472 + ], 473 + ) 474 + _write_story_activity( 475 + "personal", 476 + "20260411", 477 + "meeting_100000_300", 478 + _utc_ms("2026-04-11T10:00:00Z"), 479 + closures=[ 480 + _closure( 481 + action="sent the proposal", 482 + counterparty="Finance Team", 483 + counterparty_entity_id=None, 484 + owner_entity_id=None, 485 + ) 486 + ], 487 + ) 488 + 489 + items = ledger_surface.list(state="closed") 490 + assert len(items) == 1 491 + 492 + 493 + def test_explicit_facets_include_muted_facet(tmp_path, monkeypatch): 494 + from think.surfaces import ledger as ledger_surface 495 + 496 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 497 + _minimal_facet_tree(tmp_path, facets=("work", "quiet"), muted_facets=("quiet",)) 498 + _write_story_activity( 499 + "quiet", 500 + "20260410", 501 + "meeting_090000_300", 502 + _utc_ms("2026-04-10T09:00:00Z"), 503 + commitments=[_commitment(action="muted facet item")], 504 + ) 505 + 506 + assert ledger_surface.list(state="open") == [] 507 + explicit = ledger_surface.list(state="open", facets=["quiet"]) 508 + assert [item.action for item in explicit] == ["muted facet item"] 509 + 510 + 426 511 def test_hidden_record_exclusion(tmp_path, monkeypatch): 427 512 from think.surfaces import ledger as ledger_surface 428 513 ··· 496 581 497 582 closed_items = ledger_surface.list(state="closed") 498 583 assert [item.action for item in closed_items] == ["newer closed", "older closed"] 584 + 585 + 586 + def test_manual_dropped_does_not_override_earlier_story_closure(tmp_path, monkeypatch): 587 + from think.surfaces import ledger as ledger_surface 588 + 589 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 590 + _minimal_facet_tree(tmp_path) 591 + _write_story_activity( 592 + "work", 593 + "20260410", 594 + "meeting_090000_300", 595 + _utc_ms("2026-04-10T09:00:00Z"), 596 + commitments=[_commitment()], 597 + ) 598 + _write_story_activity( 599 + "work", 600 + "20260411", 601 + "meeting_100000_300", 602 + _utc_ms("2026-04-11T10:00:00Z"), 603 + closures=[_closure()], 604 + ) 605 + 606 + item = ledger_surface.list(state="closed")[0] 607 + refreshed = ledger_surface.close( 608 + item.id, note="operator says drop", as_state="dropped" 609 + ) 610 + 611 + assert refreshed.state == "closed" 612 + assert any( 613 + source.field == "edits" and source.activity_id == "meeting_090000_300" 614 + for source in refreshed.sources 615 + ) 616 + 617 + 618 + def test_cli_list_smoke(tmp_path, monkeypatch): 619 + from think.tools.ledger import app 620 + 621 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 622 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 623 + _minimal_facet_tree(tmp_path) 624 + _write_story_activity( 625 + "work", 626 + "20260410", 627 + "meeting_090000_300", 628 + _utc_ms("2026-04-10T09:00:00Z"), 629 + commitments=[_commitment()], 630 + ) 631 + 632 + result = _RUNNER.invoke(app, ["list", "--json"]) 633 + 634 + assert result.exit_code == 0 635 + payload = json.loads(result.stdout) 636 + assert isinstance(payload, list) 637 + assert payload[0]["summary"] == "send proposal" 638 + 639 + 640 + def test_cli_get_smoke(tmp_path, monkeypatch): 641 + from think.surfaces import ledger as ledger_surface 642 + from think.tools.ledger import app 643 + 644 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 645 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 646 + _minimal_facet_tree(tmp_path) 647 + _write_story_activity( 648 + "work", 649 + "20260410", 650 + "meeting_090000_300", 651 + _utc_ms("2026-04-10T09:00:00Z"), 652 + commitments=[_commitment()], 653 + ) 654 + item = ledger_surface.list(state="open")[0] 655 + 656 + result = _RUNNER.invoke(app, ["get", item.id, "--json"]) 657 + 658 + assert result.exit_code == 0 659 + payload = json.loads(result.stdout) 660 + assert isinstance(payload, list) 661 + assert payload[0]["id"] == item.id 662 + 663 + 664 + def test_cli_close_smoke(tmp_path, monkeypatch): 665 + from think.surfaces import ledger as ledger_surface 666 + from think.tools.ledger import app 667 + 668 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 669 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 670 + _minimal_facet_tree(tmp_path) 671 + _write_story_activity( 672 + "work", 673 + "20260410", 674 + "meeting_090000_300", 675 + _utc_ms("2026-04-10T09:00:00Z"), 676 + commitments=[_commitment()], 677 + ) 678 + item = ledger_surface.list(state="open")[0] 679 + 680 + result = _RUNNER.invoke(app, ["close", item.id, "--note", "done", "--json"]) 681 + 682 + assert result.exit_code == 0 683 + payload = json.loads(result.stdout) 684 + assert isinstance(payload, list) 685 + assert payload[0]["state"] == "closed" 686 + 687 + 688 + def test_cli_decisions_smoke(tmp_path, monkeypatch): 689 + from think.tools.ledger import app 690 + 691 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 692 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 693 + _minimal_facet_tree(tmp_path) 694 + _write_story_activity( 695 + "work", 696 + "20260410", 697 + "meeting_090000_300", 698 + _utc_ms("2026-04-10T09:00:00Z"), 699 + decisions=[_decision()], 700 + ) 701 + 702 + result = _RUNNER.invoke(app, ["decisions", "--json"]) 703 + 704 + assert result.exit_code == 0 705 + payload = json.loads(result.stdout) 706 + assert isinstance(payload, list) 707 + assert payload[0]["action"] == "move launch review"
+3
think/surfaces/ledger.py
··· 220 220 continue 221 221 owner = str(raw_commitment.get("owner") or "").strip() 222 222 action = str(raw_commitment.get("action") or "").strip() 223 + # Skip malformed story data rather than minting unusable ledger items with blank actions. 223 224 if not owner or not action: 224 225 continue 225 226 ··· 283 284 if not isinstance(raw_closure, dict): 284 285 continue 285 286 action = str(raw_closure.get("action") or "").strip() 287 + # Skip malformed story data rather than pairing closures with an empty fuzzy-match target. 286 288 if not action: 287 289 continue 288 290 owner_entity_id = raw_closure.get("owner_entity_id") ··· 357 359 consumed_story_closures.add(index) 358 360 359 361 closure_sources = matched_story_sources + manual_closes.get(entry["id"], []) 362 + # State/closed_at follow the earliest close across story and manual sources; later manual edits remain visible in sources but do not rewrite the first close. 360 363 closure_sources.sort(key=lambda candidate: candidate["sort_key"]) 361 364 first_close = closure_sources[0] if closure_sources else None 362 365 state = first_close["state"] if first_close is not None else "open"
+8 -4
think/tools/ledger.py
··· 27 27 typer.echo(jsonlib.dumps(payload, indent=2, sort_keys=False)) 28 28 29 29 30 + def _echo_json_items(items: list[object]) -> None: 31 + _echo_json([dataclasses.asdict(item) for item in items]) 32 + 33 + 30 34 def _render_table(headers: list[str], rows: list[list[str]]) -> None: 31 35 if not rows: 32 36 return ··· 117 121 except ValueError as exc: 118 122 raise typer.BadParameter(str(exc)) from exc 119 123 if json: 120 - _echo_json([dataclasses.asdict(item) for item in items]) 124 + _echo_json_items(items) 121 125 return 122 126 _render_items(items) 123 127 ··· 130 134 typer.echo(f"ledger item not found: {item_id}", err=True) 131 135 raise typer.Exit(1) 132 136 if json: 133 - _echo_json(dataclasses.asdict(item)) 137 + _echo_json_items([item]) 134 138 return 135 139 _render_items([item]) 136 140 ··· 153 157 except ValueError as exc: 154 158 raise typer.BadParameter(str(exc)) from exc 155 159 if json: 156 - _echo_json(dataclasses.asdict(item)) 160 + _echo_json_items([item]) 157 161 return 158 162 _render_items([item]) 159 163 ··· 179 183 except ValueError as exc: 180 184 raise typer.BadParameter(str(exc)) from exc 181 185 if json: 182 - _echo_json([dataclasses.asdict(item) for item in items]) 186 + _echo_json_items(items) 183 187 return 184 188 _render_decisions(items)