diff --git a/muse/journal/SKILL.md b/muse/journal/SKILL.md index 940611928..6f15f887c 100644 --- a/muse/journal/SKILL.md +++ b/muse/journal/SKILL.md @@ -90,7 +90,7 @@ sol call journal facet show # uses SOL_FACET ## facet create ```bash -sol call journal facet create [--emoji EMOJI] [--color COLOR] [--description DESC] +sol call journal facet create <title> [--emoji EMOJI] [--color COLOR] [--description DESC] [--consent] ``` Create a new facet directory and initial `facet.json`. @@ -99,6 +99,7 @@ Create a new facet directory and initial `facet.json`. - `--emoji`: optional icon emoji (default: `📦`). - `--color`: optional hex color (default: `#667eea`). - `--description`: optional description text. +- `--consent`: optional flag asserting explicit user approval was obtained; adds `"consent": true` to the audit log entry. Examples: @@ -130,11 +131,15 @@ sol call journal facet update work --description "Client work and planning" --em ## facet rename ```bash -sol call journal facet rename <name> <new-name> +sol call journal facet rename <name> <new-name> [--consent] ``` Rename a facet (directory and references in config/chat metadata). +- `name`: current facet identifier. +- `new-name`: new facet identifier. +- `--consent`: optional flag asserting explicit user approval was obtained; adds `"consent": true` to the audit log entry. + Example: ```bash @@ -172,12 +177,13 @@ sol call journal facet unmute personal ## facet delete ```bash -sol call journal facet delete <name> [--yes] +sol call journal facet delete <name> [--yes] [--consent] ``` Delete a facet directory and all its data. - `--yes`: skip confirmation prompt. +- `--consent`: optional flag asserting explicit user approval was obtained; adds `"consent": true` to the audit log entry. Example: diff --git a/muse/unified.md b/muse/unified.md index 65eeee7e4..6300138ef 100644 --- a/muse/unified.md +++ b/muse/unified.md @@ -83,7 +83,12 @@ For detailed responses, structure your answer for clarity — lead with the key ### Journal - `sol call journal events [DAY] [-f FACET]` — List events with participants, times, and summaries. - `sol call journal facet show [name]` — Show facet details. -- `sol call journal facet create <title> [--emoji EMOJI] [--color COLOR] [--description DESC]` — Create a new facet. +- `sol call journal facet create <title> [--emoji EMOJI] [--color COLOR] [--description DESC] [--consent]` — Create a new facet. Pass `--consent` to record explicit user approval in the audit log. +- `sol call journal facet update <name> [--title T] [--description D] [--emoji E] [--color C]` — Update facet metadata fields. +- `sol call journal facet rename <name> <new-name> [--consent]` — Rename a facet. Pass `--consent` to record explicit user approval in the audit log. +- `sol call journal facet mute <name>` — Hide a facet from default listings. +- `sol call journal facet unmute <name>` — Show a previously muted facet in default listings. +- `sol call journal facet delete <name> [--yes] [--consent]` — Delete a facet and all its data. Pass `--consent` to record explicit user approval in the audit log. - `sol call journal facets [--all]` — List facets. ### Awareness diff --git a/tests/test_call.py b/tests/test_call.py index 6d38624b5..68dbbe2f7 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -397,6 +397,110 @@ class TestFacetCRUD: ) assert result.exit_code == 1 + def test_facet_create_with_consent(self, facet_journal): + """Create with --consent records consent=True in log entry.""" + from datetime import datetime + + result = runner.invoke( + call_app, ["journal", "facet", "create", "Consent Facet", "--consent"] + ) + assert result.exit_code == 0 + today = datetime.now().strftime("%Y%m%d") + log_path = ( + facet_journal / "facets" / "consent-facet" / "logs" / f"{today}.jsonl" + ) + assert log_path.exists() + import json as _json + + entries = [ + _json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert len(entries) == 1 + assert entries[0]["action"] == "facet_create" + assert entries[0]["params"].get("consent") is True + + def test_facet_create_without_consent(self, facet_journal): + """Create without --consent omits consent key from log entry.""" + from datetime import datetime + + result = runner.invoke( + call_app, ["journal", "facet", "create", "No Consent Facet"] + ) + assert result.exit_code == 0 + today = datetime.now().strftime("%Y%m%d") + log_path = ( + facet_journal / "facets" / "no-consent-facet" / "logs" / f"{today}.jsonl" + ) + assert log_path.exists() + import json as _json + + entries = [ + _json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert len(entries) == 1 + assert entries[0]["action"] == "facet_create" + assert "consent" not in entries[0]["params"] + + def test_facet_rename_with_consent(self, facet_journal): + """Rename with --consent records consent=True in log entry.""" + from datetime import datetime + + result = runner.invoke( + call_app, + [ + "journal", + "facet", + "rename", + "test-facet", + "renamed-consent", + "--consent", + ], + ) + assert result.exit_code == 0 + today = datetime.now().strftime("%Y%m%d") + log_path = ( + facet_journal / "facets" / "renamed-consent" / "logs" / f"{today}.jsonl" + ) + assert log_path.exists() + import json as _json + + entries = [ + _json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert len(entries) == 1 + assert entries[0]["action"] == "facet_rename" + assert entries[0]["params"].get("consent") is True + + def test_facet_delete_with_consent(self, facet_journal): + """Delete with --consent records consent=True in journal-level log.""" + from datetime import datetime + + result = runner.invoke( + call_app, + ["journal", "facet", "delete", "test-facet", "--yes", "--consent"], + ) + assert result.exit_code == 0 + today = datetime.now().strftime("%Y%m%d") + log_path = facet_journal / "config" / "actions" / f"{today}.jsonl" + assert log_path.exists() + import json as _json + + entries = [ + _json.loads(line) + for line in log_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert any( + e["action"] == "facet_delete" and e["params"].get("consent") is True + for e in entries + ) + def test_facets_list_shows_metadata(self, facet_journal): """facets lists unmuted facets with metadata.""" result = runner.invoke(call_app, ["journal", "facets"]) diff --git a/think/facets.py b/think/facets.py index 956953096..0985c5c91 100644 --- a/think/facets.py +++ b/think/facets.py @@ -677,6 +677,8 @@ def create_facet( emoji: str = "📦", color: str = "#667eea", description: str = "", + *, + consent: bool = False, ) -> str: """Create a new facet directory with facet.json. @@ -734,15 +736,18 @@ def create_facet( pass raise + log_params: dict = { + "title": title, + "emoji": emoji, + "color": color, + "description": description, + } + if consent: + log_params["consent"] = True log_call_action( facet=slug, action="facet_create", - params={ - "title": title, - "emoji": emoji, - "color": color, - "description": description, - }, + params=log_params, ) return slug @@ -811,7 +816,7 @@ def update_facet(name: str, **kwargs: Any) -> dict[str, Any]: return changed_fields -def delete_facet(name: str) -> None: +def delete_facet(name: str, *, consent: bool = False) -> None: """Delete a facet directory and clean up references. Removes the facet directory tree and updates convey.json and chat metadata. @@ -852,10 +857,13 @@ def delete_facet(name: str) -> None: except (json.JSONDecodeError, OSError): pass + log_params: dict = {"name": name} + if consent: + log_params["consent"] = True log_call_action( facet=None, action="facet_delete", - params={"name": name}, + params=log_params, ) shutil.rmtree(facet_path) diff --git a/think/tools/call.py b/think/tools/call.py index acbd4eed2..4954790f9 100644 --- a/think/tools/call.py +++ b/think/tools/call.py @@ -200,10 +200,21 @@ def create( emoji: str = typer.Option("📦", "--emoji", help="Icon emoji."), color: str = typer.Option("#667eea", "--color", help="Hex color."), description: str = typer.Option("", "--description", help="Facet description."), + consent: bool = typer.Option( + False, + "--consent", + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", + ), ) -> None: """Create a new facet.""" try: - slug = create_facet(title, emoji=emoji, color=color, description=description) + slug = create_facet( + title, + emoji=emoji, + color=color, + description=description, + consent=consent, + ) except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) @@ -258,6 +269,11 @@ def update( def rename( name: str = typer.Argument(help="Current facet name."), new_name: str = typer.Argument(help="New facet name."), + consent: bool = typer.Option( + False, + "--consent", + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", + ), ) -> None: """Rename a facet.""" try: @@ -265,11 +281,10 @@ def rename( except ValueError as e: typer.echo(f"Error: {e}", err=True) raise typer.Exit(1) - log_call_action( - facet=new_name, - action="facet_rename", - params={"old_name": name, "new_name": new_name}, - ) + params: dict = {"old_name": name, "new_name": new_name} + if consent: + params["consent"] = True + log_call_action(facet=new_name, action="facet_rename", params=params) @facet_app.command() @@ -298,6 +313,11 @@ def unmute(name: str = typer.Argument(help="Facet name to unmute.")) -> None: def delete( name: str = typer.Argument(help="Facet name to delete."), yes: bool = typer.Option(False, "--yes", help="Skip confirmation."), + consent: bool = typer.Option( + False, + "--consent", + help="Assert that explicit user approval was obtained before calling this command (agent audit trail).", + ), ) -> None: """Delete a facet and all its data.""" if not yes: @@ -309,7 +329,7 @@ def delete( raise typer.Exit(1) try: - delete_facet(name) + delete_facet(name, consent=consent) except FileNotFoundError: typer.echo(f"Error: Facet '{name}' not found.", err=True) raise typer.Exit(1)