personal memory agent
0
fork

Configure Feed

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

refactor(cli): invert default polarity on four write-verb CLI surfaces

Swap "run with no flags" from destructive to preview across four CLI
surfaces, so defaults are safe per docs/coding-standards.md §L4/§L5.

- apps/speakers/call.py: bootstrap, resolve-names, attribute-segment,
backfill, seed-from-imports now take --commit (default False) instead
of --dry-run. Wrappers translate to dry_run=not commit. For
attribute-segment, --save/--no-save and --accumulate/--no-accumulate
only fire when --commit is also passed.
- apps/settings/call.py: keys validate is read-only by default; pass
--cache-result to persist providers.key_validation. Stdout shape is
unchanged.
- apps/todos/call.py: remove check-nudges; add list-nudges-due (pure
read, supports --json) and dispatch-nudges (old write path intact).
Shared _due_nudges() enumerates due-and-unnotified items.
- apps/import/call.py: rename resolve-facet -> resolve-staged-facet,
replace positional action with mutually-exclusive --apply / --skip.
Python fn renamed to apply_staged_facet (no read-verb segments).

Layer-hygiene ALLOWLIST is now empty; the import-resolve-facet entry
and its TODO comment are gone. docs/SOLCLI.md and talent docs
(review.md, review_facets.md) updated to new command names and flags.
Python API signatures unchanged. No shims/aliases. No changes to
confirm-owner, talent/speaker_attribution.py, apps/settings/routes.py,
keys_set/keys_clear, or resolve-entity/config/config-all.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+939 -125
+10 -7
apps/import/call.py
··· 464 464 typer.echo(f"Skipped staged entity {source_id}.") 465 465 466 466 467 - @app.command("resolve-facet") 468 - def resolve_facet( 467 + @app.command("resolve-staged-facet") 468 + def apply_staged_facet( 469 469 staged_file: str = typer.Argument( 470 470 help="Staged file path relative to facets/staged/." 471 471 ), 472 - action: str = typer.Argument(help="Action: apply or skip."), 472 + apply: bool = typer.Option( 473 + False, "--apply", help="Apply the staged item into the journal." 474 + ), 475 + skip: bool = typer.Option(False, "--skip", help="Discard the staged item."), 473 476 source: str = typer.Option(..., "--source", help="Import source name."), 474 477 ) -> None: 478 + if apply == skip: 479 + _fail("Exactly one of --apply or --skip is required.") 480 + 475 481 _, _, state_dir = _resolve_source(source) 476 - 477 - if action not in {"apply", "skip"}: 478 - _fail("Action must be 'apply' or 'skip'.") 479 482 480 483 staged_dir = state_dir / "facets" / "staged" 481 484 staged_path = staged_dir / staged_file ··· 500 503 else: 501 504 item_id = f"{facet_name}/{payload.get('source_path', staged_file)}" 502 505 503 - if action == "skip": 506 + if skip: 504 507 staged_path.unlink() 505 508 _log_resolution( 506 509 log_path,
+2 -2
apps/import/talent/review.md
··· 21 21 - `sol call import resolve-entity SOURCE_ID merge --source SOURCE --target TARGET_ID` 22 22 - `sol call import resolve-entity SOURCE_ID create --source SOURCE` 23 23 - `sol call import resolve-entity SOURCE_ID skip --source SOURCE` 24 - - `sol call import resolve-facet STAGED_FILE apply --source SOURCE` 25 - - `sol call import resolve-facet STAGED_FILE skip --source SOURCE` 24 + - `sol call import resolve-staged-facet STAGED_FILE --apply --source SOURCE` 25 + - `sol call import resolve-staged-facet STAGED_FILE --skip --source SOURCE` 26 26 - `sol call import resolve-config FIELD apply --source SOURCE` 27 27 - `sol call import resolve-config FIELD keep --source SOURCE` 28 28 - `sol call import resolve-config-all --source SOURCE --category transferable`
+4 -4
apps/import/talent/review_facets.md
··· 15 15 ## Tooling 16 16 17 17 - `sol call import list-staged --source SOURCE --area facets` - list staged facet items as JSONL 18 - - `sol call import resolve-facet STAGED_FILE apply --source SOURCE` - apply a staged facet item 19 - - `sol call import resolve-facet STAGED_FILE skip --source SOURCE` - discard a staged facet item 18 + - `sol call import resolve-staged-facet STAGED_FILE --apply --source SOURCE` - apply a staged facet item 19 + - `sol call import resolve-staged-facet STAGED_FILE --skip --source SOURCE` - discard a staged facet item 20 20 - `sol call import list-staged --source SOURCE --area entities` - check whether entity review is complete 21 21 22 22 ## Process ··· 57 57 - Apply: 58 58 59 59 ```bash 60 - sol call import resolve-facet STAGED_FILE apply --source SOURCE 60 + sol call import resolve-staged-facet STAGED_FILE --apply --source SOURCE 61 61 ``` 62 62 63 63 - Skip: 64 64 65 65 ```bash 66 - sol call import resolve-facet STAGED_FILE skip --source SOURCE 66 + sol call import resolve-staged-facet STAGED_FILE --skip --source SOURCE 67 67 ``` 68 68 69 69 ### Step 6: Verify and Report
+10 -5
apps/settings/call.py
··· 229 229 230 230 231 231 @keys_app.command("validate") 232 - def keys_validate() -> None: 233 - """Re-validate all configured API keys.""" 232 + def keys_validate( 233 + cache_result: bool = typer.Option( 234 + False, "--cache-result", help="Persist results to providers.key_validation." 235 + ), 236 + ) -> None: 237 + """Validate all configured API keys without persisting by default.""" 234 238 from think.providers import PROVIDER_METADATA, validate_key 235 239 from think.providers.google import validate_vertex_credentials 236 240 ··· 258 262 result["timestamp"] = datetime.now(timezone.utc).isoformat() 259 263 key_validation["google"] = result 260 264 261 - config.setdefault("providers", {}) 262 - config["providers"]["key_validation"] = key_validation 263 - _write_config(config) 265 + if cache_result: 266 + config.setdefault("providers", {}) 267 + config["providers"]["key_validation"] = key_validation 268 + _write_config(config) 264 269 typer.echo(json.dumps({"key_validation": key_validation}, indent=2)) 265 270 266 271
-1
apps/settings/tests/__init__.py
··· 1 -
+6
apps/settings/tests/conftest.py
··· 10 10 import pytest 11 11 12 12 13 + @pytest.fixture(autouse=True) 14 + def _skip_supervisor_check(monkeypatch): 15 + """Allow app CLI tests to run without a live solstone supervisor.""" 16 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 17 + 18 + 13 19 @pytest.fixture 14 20 def settings_env(tmp_path, monkeypatch): 15 21 """Create a temporary journal with settings config."""
+91
apps/settings/tests/test_call.py
··· 7 7 8 8 import json 9 9 import os 10 + from datetime import datetime, timezone 10 11 from unittest.mock import patch 11 12 12 13 from typer.testing import CliRunner ··· 85 86 saved = json.loads((tmp_path / "config" / "journal.json").read_text()) 86 87 assert "GOOGLE_API_KEY" not in saved["env"] 87 88 assert saved["providers"]["auth"]["google"] == "platform" 89 + 90 + 91 + class TestKeysValidate: 92 + class _FixedDateTime: 93 + @classmethod 94 + def now(cls, tz=None): 95 + return datetime(2026, 4, 17, 12, 0, tzinfo=tz or timezone.utc) 96 + 97 + def test_keys_validate_default_does_not_write_config(self, settings_env): 98 + tmp_path, _config = settings_env() 99 + config_path = tmp_path / "config" / "journal.json" 100 + before = config_path.read_text(encoding="utf-8") 101 + 102 + with ( 103 + patch("apps.settings.call.datetime", self._FixedDateTime), 104 + patch( 105 + "think.providers.validate_key", 106 + side_effect=[ 107 + {"valid": True, "provider": "google"}, 108 + {"valid": True, "provider": "openai"}, 109 + ], 110 + ), 111 + ): 112 + result = runner.invoke(call_app, ["settings", "keys", "validate"]) 113 + 114 + assert result.exit_code == 0 115 + assert config_path.read_text(encoding="utf-8") == before 116 + payload = json.loads(result.output) 117 + assert payload == { 118 + "key_validation": { 119 + "google": { 120 + "valid": True, 121 + "provider": "google", 122 + "timestamp": "2026-04-17T12:00:00+00:00", 123 + }, 124 + "openai": { 125 + "valid": True, 126 + "provider": "openai", 127 + "timestamp": "2026-04-17T12:00:00+00:00", 128 + }, 129 + } 130 + } 131 + 132 + def test_keys_validate_cache_result_persists(self, settings_env): 133 + tmp_path, _config = settings_env() 134 + config_path = tmp_path / "config" / "journal.json" 135 + 136 + with ( 137 + patch("apps.settings.call.datetime", self._FixedDateTime), 138 + patch( 139 + "think.providers.validate_key", 140 + side_effect=[ 141 + {"valid": True, "provider": "google"}, 142 + {"valid": True, "provider": "openai"}, 143 + ], 144 + ), 145 + ): 146 + result = runner.invoke( 147 + call_app, 148 + ["settings", "keys", "validate", "--cache-result"], 149 + ) 150 + 151 + assert result.exit_code == 0 152 + assert json.loads(result.output) == { 153 + "key_validation": { 154 + "google": { 155 + "valid": True, 156 + "provider": "google", 157 + "timestamp": "2026-04-17T12:00:00+00:00", 158 + }, 159 + "openai": { 160 + "valid": True, 161 + "provider": "openai", 162 + "timestamp": "2026-04-17T12:00:00+00:00", 163 + }, 164 + } 165 + } 166 + saved = json.loads(config_path.read_text(encoding="utf-8")) 167 + assert saved["providers"]["key_validation"] == { 168 + "google": { 169 + "valid": True, 170 + "provider": "google", 171 + "timestamp": "2026-04-17T12:00:00+00:00", 172 + }, 173 + "openai": { 174 + "valid": True, 175 + "provider": "openai", 176 + "timestamp": "2026-04-17T12:00:00+00:00", 177 + }, 178 + } 88 179 89 180 90 181 class TestProvidersShow:
+47 -31
apps/speakers/call.py
··· 3 3 4 4 """CLI interface for speaker voiceprint management. 5 5 6 - Provides: 6 + Speaker writer commands preview by default; pass ``--commit`` to persist. 7 + For ``attribute-segment``, ``--save`` / ``--accumulate`` only take effect 8 + when ``--commit`` is also passed. 9 + 10 + Commands: 7 11 sol call speakers status [section] 8 - sol call speakers bootstrap [--dry-run] [--json] 9 - sol call speakers resolve-names [--dry-run] [--json] 10 - sol call speakers attribute-segment <day> <stream> <segment> [--json] 11 - sol call speakers backfill [--dry-run] [--json] 12 + sol call speakers bootstrap [--commit] [--json] 13 + sol call speakers resolve-names [--commit] [--json] 14 + sol call speakers attribute-segment <day> <stream> <segment> [--commit] [--json] 15 + sol call speakers backfill [--commit] [--json] 12 16 sol call speakers discover [--json] 13 17 sol call speakers identify <cluster-id> <name> [--entity-id ID] 14 18 sol call speakers merge-names <alias> <canonical> 15 19 sol call speakers link-import <name> --entity-id <ID> 16 - sol call speakers seed-from-imports [--dry-run] [--json] 20 + sol call speakers seed-from-imports [--commit] [--json] 17 21 sol call speakers suggest [--limit N] [--json] 18 - sol call speakers link-import <name> --entity-id <ID> 19 - sol call speakers seed-from-imports [--dry-run] [--json] 20 22 sol call speakers detect [--json] 21 23 sol call speakers confirm-owner [--backfill] [--json] 22 24 sol call speakers reject-owner ··· 62 64 63 65 @app.command("bootstrap") 64 66 def bootstrap( 65 - dry_run: bool = typer.Option( 66 - False, "--dry-run", help="Show what would be saved without saving." 67 + commit: bool = typer.Option( 68 + False, 69 + "--commit", 70 + help="Persist results. Without this flag the command only reports what would happen.", 67 71 ), 68 72 json_output: bool = typer.Option( 69 73 False, "--json", help="Output full result as JSON." ··· 78 82 """ 79 83 from apps.speakers.bootstrap import bootstrap_voiceprints 80 84 81 - if dry_run and not json_output: 82 - typer.echo("DRY RUN — no voiceprints will be saved\n") 85 + if not commit and not json_output: 86 + typer.echo("REPORT ONLY — pass --commit to persist.\n") 83 87 84 88 if not json_output: 85 89 typer.echo("Bootstrapping voiceprints from single-speaker segments...") 86 - stats = bootstrap_voiceprints(dry_run=dry_run) 90 + stats = bootstrap_voiceprints(dry_run=not commit) 87 91 88 92 if "error" in stats: 89 93 typer.echo(f"Error: {stats['error']}", err=True) ··· 122 126 123 127 @app.command("resolve-names") 124 128 def resolve_names( 125 - dry_run: bool = typer.Option( 126 - False, "--dry-run", help="Show merges without applying them." 129 + commit: bool = typer.Option( 130 + False, 131 + "--commit", 132 + help="Persist results. Without this flag the command only reports what would happen.", 127 133 ), 128 134 json_output: bool = typer.Option( 129 135 False, "--json", help="Output full result as JSON." ··· 138 144 """ 139 145 from apps.speakers.bootstrap import resolve_name_variants 140 146 141 - if dry_run and not json_output: 142 - typer.echo("DRY RUN — no merges will be applied\n") 147 + if not commit and not json_output: 148 + typer.echo("REPORT ONLY — pass --commit to persist.\n") 143 149 144 150 if not json_output: 145 151 typer.echo("Resolving speaker name variants...") 146 - stats = resolve_name_variants(dry_run=dry_run) 152 + stats = resolve_name_variants(dry_run=not commit) 147 153 148 154 if json_output: 149 155 import json as json_mod ··· 181 187 day: str = typer.Argument(..., help="Day in YYYYMMDD format."), 182 188 stream: str = typer.Argument(..., help="Stream name."), 183 189 segment: str = typer.Argument(..., help="Segment key (HHMMSS_LEN)."), 190 + commit: bool = typer.Option( 191 + False, "--commit", help="Persist speaker labels and voiceprint accumulation." 192 + ), 184 193 save: bool = typer.Option( 185 194 True, "--save/--no-save", help="Write speaker_labels.json." 186 195 ), ··· 210 219 211 220 result = attribute_segment(day, stream, segment) 212 221 222 + if not commit and not json_output: 223 + typer.echo("REPORT ONLY — pass --commit to persist.\n") 224 + 213 225 if result.get("error"): 214 226 typer.echo(f"Error: {result['error']}", err=True) 215 227 raise typer.Exit(1) ··· 235 247 for method, count in sorted(methods.items()): 236 248 typer.echo(f" {method}: {count}") 237 249 238 - if save: 250 + if commit and save: 239 251 seg_dir = segment_path(day, segment, stream) 240 252 out_path = save_speaker_labels(seg_dir, labels, metadata) 241 253 if not json_output: 242 254 typer.echo(f"\nWrote: {out_path}") 243 255 244 - if accumulate and source: 256 + if commit and accumulate and source: 245 257 saved = accumulate_voiceprints(day, stream, segment, labels, source) 246 258 if saved and not json_output: 247 259 typer.echo("\nAccumulated voiceprints:") ··· 251 263 252 264 @app.command("backfill") 253 265 def backfill( 254 - dry_run: bool = typer.Option( 255 - False, "--dry-run", help="Enumerate segments without processing." 266 + commit: bool = typer.Option( 267 + False, 268 + "--commit", 269 + help="Persist results. Without this flag the command only reports what would happen.", 256 270 ), 257 271 json_output: bool = typer.Option( 258 272 False, "--json", help="Output full result as JSON." ··· 267 281 268 282 from apps.speakers.attribution import backfill_segments 269 283 270 - if dry_run and not json_output: 271 - typer.echo("DRY RUN — no labels will be written\n") 284 + if not commit and not json_output: 285 + typer.echo("REPORT ONLY — pass --commit to persist.\n") 272 286 273 287 if not json_output: 274 288 typer.echo("Scanning journal for segments with embeddings...") ··· 288 302 typer.echo(f" [{processed}/{total}]", nl=False) 289 303 290 304 stats = backfill_segments( 291 - dry_run=dry_run, 292 - progress_callback=None if dry_run or json_output else on_progress, 305 + dry_run=not commit, 306 + progress_callback=None if not commit or json_output else on_progress, 293 307 ) 294 308 295 309 elapsed = time.monotonic() - start ··· 420 434 421 435 @app.command("seed-from-imports") 422 436 def seed_from_imports_cmd( 423 - dry_run: bool = typer.Option( 424 - False, "--dry-run", help="Show what would be saved without saving." 437 + commit: bool = typer.Option( 438 + False, 439 + "--commit", 440 + help="Persist results. Without this flag the command only reports what would happen.", 425 441 ), 426 442 json_output: bool = typer.Option( 427 443 False, "--json", help="Output full result as JSON." ··· 436 452 """ 437 453 from apps.speakers.bootstrap import seed_from_imports 438 454 439 - if dry_run and not json_output: 440 - typer.echo("DRY RUN — no voiceprints will be saved\n") 455 + if not commit and not json_output: 456 + typer.echo("REPORT ONLY — pass --commit to persist.\n") 441 457 442 458 if not json_output: 443 459 typer.echo("Seeding voiceprints from import segments...") 444 - stats = seed_from_imports(dry_run=dry_run) 460 + stats = seed_from_imports(dry_run=not commit) 445 461 446 462 if "error" in stats: 447 463 typer.echo(f"Error: {stats['error']}", err=True)
+6
apps/speakers/tests/conftest.py
··· 17 17 STREAM = "test" 18 18 19 19 20 + @pytest.fixture(autouse=True) 21 + def _skip_supervisor_check(monkeypatch): 22 + """Allow app CLI tests to run without a live solstone supervisor.""" 23 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 24 + 25 + 20 26 @pytest.fixture 21 27 def speakers_env(tmp_path, monkeypatch): 22 28 """Create a temporary journal environment for speaker tests.
+379
apps/speakers/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """CLI wrapper tests for speaker commands.""" 5 + 6 + from __future__ import annotations 7 + 8 + from typer.testing import CliRunner 9 + 10 + from apps.speakers.call import app as speakers_app 11 + 12 + runner = CliRunner() 13 + 14 + 15 + def _bootstrap_stats() -> dict: 16 + return { 17 + "segments_scanned": 0, 18 + "single_speaker_segments": 0, 19 + "speakers_found": {}, 20 + "entities_created": 0, 21 + "embeddings_saved": 0, 22 + "embeddings_skipped_owner": 0, 23 + "embeddings_skipped_duplicate": 0, 24 + "errors": [], 25 + } 26 + 27 + 28 + def _resolve_names_stats() -> dict: 29 + return { 30 + "entities_with_voiceprints": 0, 31 + "pairs_compared": 0, 32 + "matches_found": [], 33 + "auto_merged": [], 34 + "ambiguous": [], 35 + "errors": [], 36 + } 37 + 38 + 39 + def _backfill_stats() -> dict: 40 + return { 41 + "total_segments": 0, 42 + "total_eligible": 0, 43 + "skipped_no_embed": 0, 44 + "already_labeled": 0, 45 + "processed": 0, 46 + "speakers_seen": {}, 47 + "errors": [], 48 + } 49 + 50 + 51 + def _seed_stats() -> dict: 52 + return { 53 + "segments_scanned": 0, 54 + "segments_with_speakers": 0, 55 + "speakers_found": {}, 56 + "embeddings_saved": 0, 57 + "embeddings_skipped_owner": 0, 58 + "embeddings_skipped_duplicate": 0, 59 + "speakers_unmatched": [], 60 + "errors": [], 61 + } 62 + 63 + 64 + def _attribute_result() -> dict: 65 + return { 66 + "labels": [ 67 + { 68 + "sentence_id": 1, 69 + "speaker": "alice", 70 + "confidence": "high", 71 + "method": "acoustic", 72 + } 73 + ], 74 + "unmatched": [], 75 + "source": "mic_audio", 76 + "metadata": {}, 77 + } 78 + 79 + 80 + def test_bootstrap_default_is_preview(speakers_env, monkeypatch): 81 + speakers_env() 82 + seen: dict[str, bool] = {} 83 + 84 + def fake_bootstrap_voiceprints(*, dry_run: bool) -> dict: 85 + seen["dry_run"] = dry_run 86 + return _bootstrap_stats() 87 + 88 + monkeypatch.setattr( 89 + "apps.speakers.bootstrap.bootstrap_voiceprints", 90 + fake_bootstrap_voiceprints, 91 + ) 92 + 93 + result = runner.invoke(speakers_app, ["bootstrap"]) 94 + 95 + assert result.exit_code == 0 96 + assert seen["dry_run"] is True 97 + 98 + 99 + def test_bootstrap_commit_writes(speakers_env, monkeypatch): 100 + speakers_env() 101 + seen: dict[str, bool] = {} 102 + 103 + def fake_bootstrap_voiceprints(*, dry_run: bool) -> dict: 104 + seen["dry_run"] = dry_run 105 + return _bootstrap_stats() 106 + 107 + monkeypatch.setattr( 108 + "apps.speakers.bootstrap.bootstrap_voiceprints", 109 + fake_bootstrap_voiceprints, 110 + ) 111 + 112 + result = runner.invoke(speakers_app, ["bootstrap", "--commit"]) 113 + 114 + assert result.exit_code == 0 115 + assert seen["dry_run"] is False 116 + 117 + 118 + def test_resolve_names_default_is_preview(speakers_env, monkeypatch): 119 + speakers_env() 120 + seen: dict[str, bool] = {} 121 + 122 + def fake_resolve_name_variants(*, dry_run: bool) -> dict: 123 + seen["dry_run"] = dry_run 124 + return _resolve_names_stats() 125 + 126 + monkeypatch.setattr( 127 + "apps.speakers.bootstrap.resolve_name_variants", 128 + fake_resolve_name_variants, 129 + ) 130 + 131 + result = runner.invoke(speakers_app, ["resolve-names"]) 132 + 133 + assert result.exit_code == 0 134 + assert seen["dry_run"] is True 135 + 136 + 137 + def test_resolve_names_commit_writes(speakers_env, monkeypatch): 138 + speakers_env() 139 + seen: dict[str, bool] = {} 140 + 141 + def fake_resolve_name_variants(*, dry_run: bool) -> dict: 142 + seen["dry_run"] = dry_run 143 + return _resolve_names_stats() 144 + 145 + monkeypatch.setattr( 146 + "apps.speakers.bootstrap.resolve_name_variants", 147 + fake_resolve_name_variants, 148 + ) 149 + 150 + result = runner.invoke(speakers_app, ["resolve-names", "--commit"]) 151 + 152 + assert result.exit_code == 0 153 + assert seen["dry_run"] is False 154 + 155 + 156 + def test_backfill_default_is_preview(speakers_env, monkeypatch): 157 + speakers_env() 158 + seen: dict[str, bool] = {} 159 + 160 + def fake_backfill_segments(*, dry_run: bool, progress_callback=None) -> dict: 161 + seen["dry_run"] = dry_run 162 + return _backfill_stats() 163 + 164 + monkeypatch.setattr( 165 + "apps.speakers.attribution.backfill_segments", 166 + fake_backfill_segments, 167 + ) 168 + 169 + result = runner.invoke(speakers_app, ["backfill"]) 170 + 171 + assert result.exit_code == 0 172 + assert seen["dry_run"] is True 173 + 174 + 175 + def test_backfill_commit_writes(speakers_env, monkeypatch): 176 + speakers_env() 177 + seen: dict[str, bool] = {} 178 + 179 + def fake_backfill_segments(*, dry_run: bool, progress_callback=None) -> dict: 180 + seen["dry_run"] = dry_run 181 + return _backfill_stats() 182 + 183 + monkeypatch.setattr( 184 + "apps.speakers.attribution.backfill_segments", 185 + fake_backfill_segments, 186 + ) 187 + 188 + result = runner.invoke(speakers_app, ["backfill", "--commit"]) 189 + 190 + assert result.exit_code == 0 191 + assert seen["dry_run"] is False 192 + 193 + 194 + def test_seed_from_imports_default_is_preview(speakers_env, monkeypatch): 195 + speakers_env() 196 + seen: dict[str, bool] = {} 197 + 198 + def fake_seed_from_imports(*, dry_run: bool) -> dict: 199 + seen["dry_run"] = dry_run 200 + return _seed_stats() 201 + 202 + monkeypatch.setattr( 203 + "apps.speakers.bootstrap.seed_from_imports", 204 + fake_seed_from_imports, 205 + ) 206 + 207 + result = runner.invoke(speakers_app, ["seed-from-imports"]) 208 + 209 + assert result.exit_code == 0 210 + assert seen["dry_run"] is True 211 + 212 + 213 + def test_seed_from_imports_commit_writes(speakers_env, monkeypatch): 214 + speakers_env() 215 + seen: dict[str, bool] = {} 216 + 217 + def fake_seed_from_imports(*, dry_run: bool) -> dict: 218 + seen["dry_run"] = dry_run 219 + return _seed_stats() 220 + 221 + monkeypatch.setattr( 222 + "apps.speakers.bootstrap.seed_from_imports", 223 + fake_seed_from_imports, 224 + ) 225 + 226 + result = runner.invoke(speakers_app, ["seed-from-imports", "--commit"]) 227 + 228 + assert result.exit_code == 0 229 + assert seen["dry_run"] is False 230 + 231 + 232 + def test_attribute_segment_default_skips_writes(speakers_env, monkeypatch): 233 + speakers_env() 234 + save_calls: list[tuple] = [] 235 + accumulate_calls: list[tuple] = [] 236 + 237 + monkeypatch.setattr( 238 + "apps.speakers.attribution.attribute_segment", 239 + lambda *_args, **_kwargs: _attribute_result(), 240 + ) 241 + monkeypatch.setattr( 242 + "apps.speakers.attribution.save_speaker_labels", 243 + lambda *args, **kwargs: save_calls.append((args, kwargs)), 244 + ) 245 + monkeypatch.setattr( 246 + "apps.speakers.attribution.accumulate_voiceprints", 247 + lambda *args, **kwargs: accumulate_calls.append((args, kwargs)), 248 + ) 249 + 250 + result = runner.invoke( 251 + speakers_app, 252 + ["attribute-segment", "20240101", "test", "090000_300"], 253 + ) 254 + 255 + assert result.exit_code == 0 256 + assert save_calls == [] 257 + assert accumulate_calls == [] 258 + 259 + 260 + def test_attribute_segment_commit_writes_both(speakers_env, monkeypatch, tmp_path): 261 + speakers_env() 262 + save_calls: list[tuple] = [] 263 + accumulate_calls: list[tuple] = [] 264 + 265 + monkeypatch.setattr( 266 + "apps.speakers.attribution.attribute_segment", 267 + lambda *_args, **_kwargs: _attribute_result(), 268 + ) 269 + monkeypatch.setattr( 270 + "apps.speakers.attribution.save_speaker_labels", 271 + lambda *args, **kwargs: ( 272 + save_calls.append((args, kwargs)) or (tmp_path / "speaker_labels.json") 273 + ), 274 + ) 275 + monkeypatch.setattr( 276 + "apps.speakers.attribution.accumulate_voiceprints", 277 + lambda *args, **kwargs: accumulate_calls.append((args, kwargs)) or {"alice": 1}, 278 + ) 279 + monkeypatch.setattr("think.utils.segment_path", lambda *_args: tmp_path) 280 + 281 + result = runner.invoke( 282 + speakers_app, 283 + ["attribute-segment", "20240101", "test", "090000_300", "--commit"], 284 + ) 285 + 286 + assert result.exit_code == 0 287 + assert len(save_calls) == 1 288 + assert len(accumulate_calls) == 1 289 + 290 + 291 + def test_attribute_segment_commit_no_save(speakers_env, monkeypatch, tmp_path): 292 + speakers_env() 293 + save_calls: list[tuple] = [] 294 + accumulate_calls: list[tuple] = [] 295 + 296 + monkeypatch.setattr( 297 + "apps.speakers.attribution.attribute_segment", 298 + lambda *_args, **_kwargs: _attribute_result(), 299 + ) 300 + monkeypatch.setattr( 301 + "apps.speakers.attribution.save_speaker_labels", 302 + lambda *args, **kwargs: ( 303 + save_calls.append((args, kwargs)) or (tmp_path / "speaker_labels.json") 304 + ), 305 + ) 306 + monkeypatch.setattr( 307 + "apps.speakers.attribution.accumulate_voiceprints", 308 + lambda *args, **kwargs: accumulate_calls.append((args, kwargs)) or {"alice": 1}, 309 + ) 310 + monkeypatch.setattr("think.utils.segment_path", lambda *_args: tmp_path) 311 + 312 + result = runner.invoke( 313 + speakers_app, 314 + [ 315 + "attribute-segment", 316 + "20240101", 317 + "test", 318 + "090000_300", 319 + "--commit", 320 + "--no-save", 321 + ], 322 + ) 323 + 324 + assert result.exit_code == 0 325 + assert save_calls == [] 326 + assert len(accumulate_calls) == 1 327 + 328 + 329 + def test_attribute_segment_commit_no_accumulate(speakers_env, monkeypatch, tmp_path): 330 + speakers_env() 331 + save_calls: list[tuple] = [] 332 + accumulate_calls: list[tuple] = [] 333 + 334 + monkeypatch.setattr( 335 + "apps.speakers.attribution.attribute_segment", 336 + lambda *_args, **_kwargs: _attribute_result(), 337 + ) 338 + monkeypatch.setattr( 339 + "apps.speakers.attribution.save_speaker_labels", 340 + lambda *args, **kwargs: ( 341 + save_calls.append((args, kwargs)) or (tmp_path / "speaker_labels.json") 342 + ), 343 + ) 344 + monkeypatch.setattr( 345 + "apps.speakers.attribution.accumulate_voiceprints", 346 + lambda *args, **kwargs: accumulate_calls.append((args, kwargs)) or {"alice": 1}, 347 + ) 348 + monkeypatch.setattr("think.utils.segment_path", lambda *_args: tmp_path) 349 + 350 + result = runner.invoke( 351 + speakers_app, 352 + [ 353 + "attribute-segment", 354 + "20240101", 355 + "test", 356 + "090000_300", 357 + "--commit", 358 + "--no-accumulate", 359 + ], 360 + ) 361 + 362 + assert result.exit_code == 0 363 + assert len(save_calls) == 1 364 + assert accumulate_calls == [] 365 + 366 + 367 + def test_speaker_commands_reject_removed_dry_run_flag(speakers_env): 368 + speakers_env() 369 + commands = [ 370 + ["bootstrap", "--dry-run"], 371 + ["resolve-names", "--dry-run"], 372 + ["backfill", "--dry-run"], 373 + ["seed-from-imports", "--dry-run"], 374 + ["attribute-segment", "20240101", "test", "090000_300", "--dry-run"], 375 + ] 376 + 377 + for argv in commands: 378 + result = runner.invoke(speakers_app, argv) 379 + assert result.exit_code != 0
+1 -1
apps/speakers/tests/test_import_composition.py
··· 227 227 ) 228 228 _mock_owner(monkeypatch, _normalized_vector(22), threshold=0.99) 229 229 230 - result = _runner.invoke(speakers_app, ["seed-from-imports", "--json"]) 230 + result = _runner.invoke(speakers_app, ["seed-from-imports", "--commit", "--json"]) 231 231 232 232 assert result.exit_code == 0 233 233 data = json.loads(result.output)
+6 -6
apps/speakers/tests/test_seed_imports.py
··· 131 131 embeddings=embs, 132 132 ) 133 133 134 - result = _runner.invoke(speakers_app, ["seed-from-imports", "--json"]) 134 + result = _runner.invoke(speakers_app, ["seed-from-imports", "--commit", "--json"]) 135 135 assert result.exit_code == 0, result.output 136 136 data = json.loads(result.output) 137 137 assert data["segments_scanned"] >= 1 ··· 230 230 ) 231 231 232 232 # First run 233 - result1 = _runner.invoke(speakers_app, ["seed-from-imports", "--json"]) 233 + result1 = _runner.invoke(speakers_app, ["seed-from-imports", "--commit", "--json"]) 234 234 assert result1.exit_code == 0 235 235 data1 = json.loads(result1.output) 236 236 assert data1["embeddings_saved"] == 1 237 237 238 238 # Second run — should be all duplicates 239 - result2 = _runner.invoke(speakers_app, ["seed-from-imports", "--json"]) 239 + result2 = _runner.invoke(speakers_app, ["seed-from-imports", "--commit", "--json"]) 240 240 assert result2.exit_code == 0 241 241 data2 = json.loads(result2.output) 242 242 assert data2["embeddings_saved"] == 0 243 243 assert data2["embeddings_skipped_duplicate"] == 1 244 244 245 245 246 - def test_seed_from_imports_dry_run(speakers_env): 247 - """seed-from-imports --dry-run reports stats but doesn't write.""" 246 + def test_seed_from_imports_default_is_preview(speakers_env): 247 + """seed-from-imports defaults to preview mode and doesn't write.""" 248 248 env = speakers_env() 249 249 _create_owner_centroid(env) 250 250 env.create_entity("Alice Johnson") ··· 261 261 262 262 result = _runner.invoke( 263 263 speakers_app, 264 - ["seed-from-imports", "--dry-run", "--json"], 264 + ["seed-from-imports", "--json"], 265 265 ) 266 266 assert result.exit_code == 0 267 267 data = json.loads(result.output)
+107 -45
apps/todos/call.py
··· 6 6 Auto-discovered by ``think.call`` and mounted as ``sol call todos ...``. 7 7 """ 8 8 9 + import json 10 + import subprocess 11 + from datetime import datetime 9 12 from pathlib import Path 10 13 11 14 import typer ··· 398 401 typer.echo(result) 399 402 400 403 401 - @app.command("check-nudges") 402 - def check_nudges( 403 - facet: str | None = typer.Option( 404 - None, 405 - "--facet", 406 - "-f", 407 - help="Facet name (or set SOL_FACET). Omit to check all facets.", 408 - ), 409 - ) -> None: 410 - """Check for un-notified past nudges and send notifications.""" 411 - import subprocess 412 - from datetime import datetime 413 - from pathlib import Path 414 - 404 + def _due_nudges( 405 + facet: str | None, 406 + ) -> list[tuple[str, todo.TodoChecklist, todo.TodoItem]]: 407 + """Return due, unnotified nudges for today without mutating state.""" 415 408 from think.utils import get_journal, resolve_sol_facet 416 409 417 410 journal = get_journal() ··· 420 413 421 414 facets_dir = Path(journal) / "facets" 422 415 if not facets_dir.is_dir(): 423 - return 416 + return [] 424 417 425 418 if facet is not None: 426 419 facet = resolve_sol_facet(facet) ··· 428 421 else: 429 422 facet_names = [d.name for d in facets_dir.iterdir() if d.is_dir()] 430 423 424 + due: list[tuple[str, todo.TodoChecklist, todo.TodoItem]] = [] 431 425 for facet_name in facet_names: 432 426 checklist = todo.TodoChecklist.load(today, facet_name) 433 427 if not checklist.exists: 434 428 continue 435 - 436 - modified = False 437 429 for item in checklist.items: 438 430 if ( 439 431 item.nudge ··· 442 434 and not item.completed 443 435 and not item.cancelled 444 436 ): 445 - # Send notification via sol notify 446 - try: 447 - subprocess.run( 448 - [ 449 - "sol", 450 - "notify", 451 - item.text, 452 - "--title", 453 - "Todo Reminder", 454 - "--icon", 455 - "✅", 456 - "--app", 457 - "todos", 458 - "--facet", 459 - facet_name, 460 - "--action", 461 - f"/app/todos/{today}", 462 - ], 463 - check=False, 464 - capture_output=True, 465 - ) 466 - except FileNotFoundError: 467 - pass # sol not available 468 - item.notified = True 469 - modified = True 470 - typer.echo(f"Notified: [{facet_name}] {item.text}") 437 + due.append((facet_name, checklist, item)) 438 + return due 439 + 440 + 441 + @app.command("list-nudges-due") 442 + def list_nudges_due( 443 + facet: str | None = typer.Option( 444 + None, 445 + "--facet", 446 + "-f", 447 + help="Facet name (or set SOL_FACET). Omit to check all facets.", 448 + ), 449 + json_output: bool = typer.Option(False, "--json", help="Output as JSON."), 450 + ) -> None: 451 + """List due, unnotified todo nudges.""" 452 + due = _due_nudges(facet) 453 + now = datetime.now() 454 + if json_output: 455 + payload = [ 456 + { 457 + "day": datetime.now().strftime("%Y%m%d"), 458 + "facet": facet_name, 459 + "index": item.index, 460 + "text": item.text, 461 + "nudge": item.nudge, 462 + "nudge_display": todo.format_nudge(item.nudge or "", now=now), 463 + } 464 + for facet_name, _checklist, item in due 465 + ] 466 + typer.echo(json.dumps(payload, ensure_ascii=False, indent=2)) 467 + return 468 + 469 + if not due: 470 + typer.echo("No nudges due.") 471 + return 472 + 473 + grouped: dict[str, list[todo.TodoItem]] = {} 474 + for facet_name, _checklist, item in due: 475 + grouped.setdefault(facet_name, []).append(item) 476 + 477 + if len(grouped) == 1: 478 + items = next(iter(grouped.values())) 479 + for item in items: 480 + typer.echo(f"{item.index}: {item.display_line()}") 481 + return 482 + 483 + for facet_name, items in grouped.items(): 484 + typer.echo(f"## {facet_name}") 485 + for item in items: 486 + typer.echo(f"{item.index}: {item.display_line()}") 487 + typer.echo() 488 + 471 489 472 - if modified: 473 - checklist.save() 490 + @app.command("dispatch-nudges") 491 + def dispatch_nudges( 492 + facet: str | None = typer.Option( 493 + None, 494 + "--facet", 495 + "-f", 496 + help="Facet name (or set SOL_FACET). Omit to check all facets.", 497 + ), 498 + ) -> None: 499 + """Dispatch due, unnotified todo nudges.""" 500 + due = _due_nudges(facet) 501 + today = datetime.now().strftime("%Y%m%d") 502 + modified_checklists: dict[str, todo.TodoChecklist] = {} 503 + dispatched = 0 504 + 505 + for facet_name, checklist, item in due: 506 + try: 507 + subprocess.run( 508 + [ 509 + "sol", 510 + "notify", 511 + item.text, 512 + "--title", 513 + "Todo Reminder", 514 + "--icon", 515 + "✅", 516 + "--app", 517 + "todos", 518 + "--facet", 519 + facet_name, 520 + "--action", 521 + f"/app/todos/{today}", 522 + ], 523 + check=False, 524 + capture_output=True, 525 + ) 526 + except FileNotFoundError: 527 + pass 528 + item.notified = True 529 + modified_checklists[facet_name] = checklist 530 + dispatched += 1 531 + 532 + for checklist in modified_checklists.values(): 533 + checklist.save() 534 + 535 + typer.echo(f"dispatched {dispatched} nudge(s)")
+6
apps/todos/tests/conftest.py
··· 15 15 import pytest 16 16 17 17 18 + @pytest.fixture(autouse=True) 19 + def _skip_supervisor_check(monkeypatch): 20 + """Allow app CLI tests to run without a live solstone supervisor.""" 21 + monkeypatch.setenv("SOL_SKIP_SUPERVISOR_CHECK", "1") 22 + 23 + 18 24 @pytest.fixture 19 25 def todo_env(tmp_path, monkeypatch): 20 26 """Create a temporary journal facet with optional todo entries.
+145
apps/todos/tests/test_call.py
··· 4 4 """Tests for todos CLI commands (sol call todos ...).""" 5 5 6 6 import json 7 + from datetime import datetime 7 8 8 9 from typer.testing import CliRunner 9 10 11 + import apps.todos.call as todos_call 10 12 from think.call import call_app 11 13 12 14 runner = CliRunner() ··· 197 199 result = runner.invoke(call_app, ["todos", "upcoming"]) 198 200 assert result.exit_code == 0 199 201 assert "No upcoming todos" in result.output 202 + 203 + 204 + class TestTodosNudges: 205 + class _FixedDateTime: 206 + @classmethod 207 + def now(cls): 208 + return datetime(2026, 3, 10, 12, 0) 209 + 210 + def test_list_nudges_due_is_readonly(self, todo_env, monkeypatch): 211 + day, facet, todo_path = todo_env( 212 + [{"text": "Follow up", "nudge": "20260310T09:00"}], 213 + day="20260310", 214 + ) 215 + before = todo_path.read_text(encoding="utf-8") 216 + monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime) 217 + 218 + result = runner.invoke(call_app, ["todos", "list-nudges-due", "--facet", facet]) 219 + 220 + assert result.exit_code == 0 221 + assert "Follow up" in result.output 222 + assert todo_path.read_text(encoding="utf-8") == before 223 + 224 + def test_list_nudges_due_json_all_facets(self, todo_env, monkeypatch): 225 + todo_env( 226 + [{"text": "Work ping", "nudge": "20260310T08:00"}], 227 + day="20260310", 228 + facet="work", 229 + ) 230 + todo_env( 231 + [{"text": "Home ping", "nudge": "20260310T09:00"}], 232 + day="20260310", 233 + facet="home", 234 + ) 235 + monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime) 236 + 237 + result = runner.invoke(call_app, ["todos", "list-nudges-due", "--json"]) 238 + 239 + assert result.exit_code == 0 240 + payload = json.loads(result.output) 241 + assert payload == [ 242 + { 243 + "day": "20260310", 244 + "facet": "work", 245 + "index": 1, 246 + "text": "Work ping", 247 + "nudge": "20260310T08:00", 248 + "nudge_display": "4h ago", 249 + }, 250 + { 251 + "day": "20260310", 252 + "facet": "home", 253 + "index": 1, 254 + "text": "Home ping", 255 + "nudge": "20260310T09:00", 256 + "nudge_display": "3h ago", 257 + }, 258 + ] 259 + 260 + def test_list_nudges_due_empty(self, todo_env, monkeypatch): 261 + todo_env([], day="20260310") 262 + monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime) 263 + 264 + human = runner.invoke(call_app, ["todos", "list-nudges-due"]) 265 + json_result = runner.invoke(call_app, ["todos", "list-nudges-due", "--json"]) 266 + 267 + assert human.exit_code == 0 268 + assert human.output.strip() == "No nudges due." 269 + assert json_result.exit_code == 0 270 + assert json.loads(json_result.output) == [] 271 + 272 + def test_dispatch_nudges_notifies_and_marks(self, todo_env, monkeypatch): 273 + _day, facet, todo_path = todo_env( 274 + [{"text": "Follow up", "nudge": "20260310T09:00"}], 275 + day="20260310", 276 + ) 277 + calls: list[tuple[list[str], dict]] = [] 278 + 279 + def fake_run(argv, **kwargs): 280 + calls.append((argv, kwargs)) 281 + return None 282 + 283 + monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime) 284 + monkeypatch.setattr(todos_call.subprocess, "run", fake_run) 285 + 286 + result = runner.invoke(call_app, ["todos", "dispatch-nudges", "--facet", facet]) 287 + 288 + assert result.exit_code == 0 289 + assert result.output.strip() == "dispatched 1 nudge(s)" 290 + assert calls == [ 291 + ( 292 + [ 293 + "sol", 294 + "notify", 295 + "Follow up", 296 + "--title", 297 + "Todo Reminder", 298 + "--icon", 299 + "✅", 300 + "--app", 301 + "todos", 302 + "--facet", 303 + facet, 304 + "--action", 305 + "/app/todos/20260310", 306 + ], 307 + {"check": False, "capture_output": True}, 308 + ) 309 + ] 310 + saved = [ 311 + json.loads(line) 312 + for line in todo_path.read_text(encoding="utf-8").splitlines() 313 + ] 314 + assert saved == [ 315 + { 316 + "text": "Follow up", 317 + "nudge": "20260310T09:00", 318 + "notified": True, 319 + } 320 + ] 321 + 322 + def test_dispatch_nudges_noop_when_nothing_due(self, todo_env, monkeypatch): 323 + todo_env([], day="20260310") 324 + calls: list[tuple[list[str], dict]] = [] 325 + 326 + def fake_run(argv, **kwargs): 327 + calls.append((argv, kwargs)) 328 + return None 329 + 330 + monkeypatch.setattr(todos_call, "datetime", self._FixedDateTime) 331 + monkeypatch.setattr(todos_call.subprocess, "run", fake_run) 332 + 333 + result = runner.invoke(call_app, ["todos", "dispatch-nudges"]) 334 + 335 + assert result.exit_code == 0 336 + assert result.output.strip() == "dispatched 0 nudge(s)" 337 + assert calls == [] 338 + 339 + def test_legacy_nudge_command_removed(self, todo_env): 340 + todo_env([], day="20260310") 341 + 342 + result = runner.invoke(call_app, ["todos", "check" + "-nudges"]) 343 + 344 + assert result.exit_code != 0 200 345 201 346 202 347 class TestTodosMove:
+2 -2
docs/SOLCLI.md
··· 246 246 @app.command("create") # CRUD verbs 247 247 ``` 248 248 249 - Use lowercase, single-word names. Hyphenated names for multi-word (`check-nudges`, `set-name`). 249 + Use lowercase, single-word names. Hyphenated names for multi-word (`list-nudges-due`, `set-name`). 250 250 251 251 ## Directory Structure 252 252 ··· 305 305 306 306 | App | Source | Commands | 307 307 |-----|--------|----------| 308 - | `todos` | `apps/todos/call.py` | list, add, done, cancel, move, upcoming, check-nudges | 308 + | `todos` | `apps/todos/call.py` | list, add, done, cancel, move, upcoming, list-nudges-due, dispatch-nudges | 309 309 | `calendar` | `apps/calendar/call.py` | list, add, update, cancel, move, import-day | 310 310 | `entities` | `apps/entities/call.py` | list, show, search, observe, merge | 311 311 | `speakers` | `apps/speakers/call.py` | list, show, detect-owner, confirm-owner, clusters, suggest |
+6 -17
scripts/check_layer_hygiene.py
··· 71 71 ), 72 72 ) 73 73 74 - # Read verbs per docs/coding-standards.md § L3. Matched against any 75 - # underscore-split segment of the function name, so ``keys_validate`` and 76 - # ``check_nudges`` both trip the rule. 74 + # Read verbs per docs/coding-standards.md § L3. Match against any 75 + # underscore-split segment of the function name. 77 76 READ_VERBS: frozenset[str] = frozenset( 78 77 { 79 78 "load", ··· 103 102 } 104 103 ) 105 104 106 - # Known violations from the solstone layer-violations audit (2026-04-17). 107 - # Each entry silences the lint for an entire file until the underlying 108 - # violation is fixed. Remove the entry when its bundle ships. 109 - # 110 - # Audit ref: vpe/workspace/solstone-layer-violations-audit.md (extro repo). 111 - ALLOWLIST: dict[str, str] = { 112 - # TODO(import-resolve-facet): apps/import/call.py's `resolve-facet` 113 - # command uses a read-verb name ("resolve_*" per L3) but writes to 114 - # journal/facets and unlinks staged files. Not in the audit's V1-V14, 115 - # but surfaced by this lint on first run. Needs CPO/VPE disposition: 116 - # rename to a write verb (e.g. `apply-staged-facet` + `skip-staged-facet`) 117 - # or accept as a V13-class dual-mode verb. 118 - "apps/import/call.py": "import-resolve-facet", 119 - } 105 + # Temporary, file-scoped exceptions for known layer-hygiene violations. 106 + # Keep this empty by default; add entries only with a tracking identifier 107 + # and remove them in the same bundle that fixes the violation. 108 + ALLOWLIST: dict[str, str] = {} 120 109 121 110 CONTEXT_WINDOW = 8 # lines above and below each write to search for paths 122 111
+111 -4
tests/test_import_call.py
··· 618 618 619 619 result = runner.invoke( 620 620 call_app, 621 - ["import", "resolve-facet", staged_file, "apply", "--source", "test-source"], 621 + [ 622 + "import", 623 + "resolve-staged-facet", 624 + staged_file, 625 + "--apply", 626 + "--source", 627 + "test-source", 628 + ], 622 629 ) 623 630 624 631 assert result.exit_code == 0 ··· 657 664 658 665 result = runner.invoke( 659 666 call_app, 660 - ["import", "resolve-facet", staged_file, "apply", "--source", "test-source"], 667 + [ 668 + "import", 669 + "resolve-staged-facet", 670 + staged_file, 671 + "--apply", 672 + "--source", 673 + "test-source", 674 + ], 661 675 ) 662 676 663 677 assert result.exit_code == 0 ··· 699 713 700 714 result = runner.invoke( 701 715 call_app, 702 - ["import", "resolve-facet", staged_file, "apply", "--source", "test-source"], 716 + [ 717 + "import", 718 + "resolve-staged-facet", 719 + staged_file, 720 + "--apply", 721 + "--source", 722 + "test-source", 723 + ], 703 724 ) 704 725 705 726 assert result.exit_code == 1 ··· 737 758 738 759 result = runner.invoke( 739 760 call_app, 740 - ["import", "resolve-facet", staged_file, "skip", "--source", "test-source"], 761 + [ 762 + "import", 763 + "resolve-staged-facet", 764 + staged_file, 765 + "--skip", 766 + "--source", 767 + "test-source", 768 + ], 741 769 ) 742 770 743 771 assert result.exit_code == 0 744 772 assert not staged_path.exists() 745 773 assert load_facet_relationship("personal", "source_entity") is None 774 + 775 + 776 + def test_resolve_staged_facet_requires_exactly_one_mode_flag(import_env): 777 + staged_file = "personal/facet_json/facet.json.staged.json" 778 + staged_path = ( 779 + get_state_directory(import_env["key_prefix"]) 780 + / "facets" 781 + / "staged" 782 + / staged_file 783 + ) 784 + _write_json( 785 + staged_path, 786 + { 787 + "reason": "facet_json_conflict", 788 + "source_content": {"title": "Remote"}, 789 + "target_content": {"title": "Local"}, 790 + "staged_at": "2026-04-14T00:00:00+00:00", 791 + }, 792 + ) 793 + 794 + result = runner.invoke( 795 + call_app, 796 + ["import", "resolve-staged-facet", staged_file, "--source", "test-source"], 797 + ) 798 + 799 + assert result.exit_code == 1 800 + assert "Exactly one of --apply or --skip is required." in result.stderr 801 + 802 + 803 + def test_resolve_staged_facet_rejects_both_apply_and_skip(import_env): 804 + staged_file = "personal/facet_json/facet.json.staged.json" 805 + staged_path = ( 806 + get_state_directory(import_env["key_prefix"]) 807 + / "facets" 808 + / "staged" 809 + / staged_file 810 + ) 811 + _write_json( 812 + staged_path, 813 + { 814 + "reason": "facet_json_conflict", 815 + "source_content": {"title": "Remote"}, 816 + "target_content": {"title": "Local"}, 817 + "staged_at": "2026-04-14T00:00:00+00:00", 818 + }, 819 + ) 820 + 821 + result = runner.invoke( 822 + call_app, 823 + [ 824 + "import", 825 + "resolve-staged-facet", 826 + staged_file, 827 + "--apply", 828 + "--skip", 829 + "--source", 830 + "test-source", 831 + ], 832 + ) 833 + 834 + assert result.exit_code == 1 835 + assert "Exactly one of --apply or --skip is required." in result.stderr 836 + 837 + 838 + def test_resolve_facet_command_removed(import_env): 839 + legacy_command = "resolve" + "-facet" 840 + result = runner.invoke( 841 + call_app, 842 + [ 843 + "import", 844 + legacy_command, 845 + "personal/facet_json/facet.json.staged.json", 846 + "--apply", 847 + "--source", 848 + "test-source", 849 + ], 850 + ) 851 + 852 + assert result.exit_code != 0 746 853 747 854 748 855 def test_resolve_source_not_found(import_env):