From 75f98f910a2210ceee573fdcab4e0f79739f2eb7 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Wed, 18 Mar 2026 16:08:44 -0600 Subject: [PATCH] Add facet consent audit flags Add --consent to the journal facet create, rename, and delete CLI commands and thread consent through create_facet() and delete_facet() so audit entries record explicit approval without rewriting log files. Update the unified journal docs and journal skill to document the new flag on create, rename, and delete, and add facet CLI tests covering consent logging for create, rename, and delete. --- muse/journal/SKILL.md | 12 +++-- muse/unified.md | 7 ++- tests/test_call.py | 104 ++++++++++++++++++++++++++++++++++++++++++ think/facets.py | 24 ++++++---- think/tools/call.py | 34 +++++++++++--- 5 files changed, 162 insertions(+), 19 deletions(-) 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) -- 2.51.2