diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6fc56a1db..1988d0ac4 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,6 +1,6 @@ # Commands -Twenty-eight CLI subcommands. The retrieval/feedback ones are also exposed as MCP tools (see [MCP](MCP.md)) and slash commands (see [SLASH_COMMANDS](SLASH_COMMANDS.md)). Lifecycle commands (`setup`, `doctor`, `migrate`, `upgrade`, `uninstall`, etc.) are CLI-only. +Twenty-nine CLI subcommands. The retrieval/feedback ones are also exposed as MCP tools (see [MCP](MCP.md)) and slash commands (see [SLASH_COMMANDS](SLASH_COMMANDS.md)). Lifecycle commands (`setup`, `doctor`, `migrate`, `upgrade`, `uninstall`, etc.) are CLI-only. ``` aelf [args] [options] @@ -22,6 +22,7 @@ DB resolves from `$AELFRICE_DB`, then `/aelfrice/memory.db` when | `demote ` | Drop a user lock (one tier per call: lock first, then user_validated). Delegates to `unlock` for the lock-drop path so an audit row is always written. | | `promote [--source user_validated]` | Promote an `agent_inferred` belief to `user_validated`. Alias of `validate`; identical semantics and flags. | | `validate [--source user_validated]` | Promote an `agent_inferred` belief to a user-validated origin (v1.2+). | +| `confirm [--source S] [--note TEXT]` | Explicit user affirmation: α += 1.0 with source `user_confirmed` (default). Writes to `feedback_history`. Note is printed on success but not persisted. Distinct from `lock` (no freeze) and from implicit retrieval feedback (`used` signal). MCP sibling: `aelf_confirm` (#390). | | `feedback [--source S]` | `used` ⇒ α += 1; `harmful` ⇒ β += 1. Harmful feedback through outbound `CONTRADICTS` threads to user-locks bumps their demotion_pressure; ≥5 ⇒ auto-demote. | | `resolve` | Sweep unresolved `CONTRADICTS` threads. Picks a winner per precedence (`user_stated > user_corrected > document_recent`) and inserts a `SUPERSEDES` thread. Idempotent. | | `reason [--seed-id ID]... [--k N] [--depth N] [--budget N] [--fanout N] [--json]` | (v2.0+, #389) Surface a reasoning chain over the belief graph. Default seeds: top-3 BM25 hits over ``; `--seed-id` overrides (repeatable). Walks `expand_bfs` with terminal-tight defaults (depth=2, budget=10, fanout=8). Default output is an indented hop tree with edge-type breadcrumbs and path-scores; `--json` for tooling. Read-only over the graph. | diff --git a/docs/feature-aelf-confirm-cli.md b/docs/feature-aelf-confirm-cli.md new file mode 100644 index 000000000..6d7bc6758 --- /dev/null +++ b/docs/feature-aelf-confirm-cli.md @@ -0,0 +1,113 @@ +# Feature spec: `aelf confirm` CLI (#441) + +**Status:** implementation spec +**Issue:** #441 +**MCP sibling:** `aelf_confirm` / `tool_confirm` — shipped in #390 +**`tool_confirm` location:** `src/aelfrice/mcp_server.py:432-476` + +--- + +## Purpose + +Explicit user affirmation of an existing belief. `aelf confirm ` tells +the system "I've checked this belief and it is correct." The signal is stronger +than the implicit "got used" signal emitted by retrieval hooks, because it carries +the user's intent rather than being inferred from continuation behaviour. + +--- + +## Contract + +``` +aelf confirm [--source SRC] [--note TEXT] +``` + +| Argument | Required | Default | Notes | +|---|---|---|---| +| `belief_id` | yes | — | The ID of the belief to affirm. | +| `--source` | no | `user_confirmed` | Written to `feedback_history.source`. Override to tag automated scripts. | +| `--note` | no | `""` | Free-text annotation; appears in stdout on success but is **not persisted** to the store. | + +Exit codes: + +- `0` — applied successfully. +- `1` — unknown belief ID, or store write error. + +--- + +## Output (stdout on success) + +``` +confirmed : alpha 1.000->2.000, mean 0.500->0.667 +``` + +Fields: + +- `prior_alpha -> new_alpha` — raw Beta parameter before and after the update. +- `mean` — `new_alpha / (new_alpha + new_beta)` rounded to 3 d.p., so the + posterior direction is immediately readable without mental arithmetic. + +On unknown belief, writes to **stderr** and exits 1: + +``` +confirm error: unknown belief: +``` + +--- + +## Semantics — how `confirm` differs from related verbs + +### vs. `aelf feedback used` + +`feedback used` is the implicit signal emitted when retrieval hooks observe a +belief was retrieved and then the continuation referenced it. `confirm` is an +*explicit* user affirmation carrying `source="user_confirmed"`, which is +distinguishable in `feedback_history` queries and in the `aelf status` counts. +Same Beta-Bernoulli mechanic (`α += 1.0`); different intent and source label. + +### vs. `aelf lock ` + +`lock` freezes the belief as user-asserted ground truth (`lock_level=user`), +giving it maximum retrieval priority and protecting it from demotion pressure +until `aelf unlock` is called. `confirm` applies one unit of positive feedback +to the Beta posterior without freezing the belief. Use `lock` when you want the +belief treated as canonical; use `confirm` when you want to nudge the posterior +without the commitment of a ground-truth freeze. + +--- + +## Storage layer + +`confirm` calls `apply_feedback(store, belief_id=..., valence=1.0, source=...)`, +which writes one row to **`feedback_history`**. This matches the MCP sibling +`tool_confirm` exactly. + +### Reconciliation with issue #441 body + +Issue #441 acceptance criterion #2 reads: *"Writes a row to the +`belief_corroborations` table (#190)."* This is incorrect / out of date. + +The shipped `tool_confirm` (MCP, #390) writes to `feedback_history` via +`apply_feedback`, not to `belief_corroborations`. The `belief_corroborations` +table tracks *duplicate re-ingests* of the same content-hash from different +sources — a structural dedup concern, not a user-affirmation signal. The CLI +implementation follows the MCP — `feedback_history` only. + +--- + +## Implementation notes + +- `_cmd_confirm` in `src/aelfrice/cli.py` mirrors `_cmd_unlock` in structure. +- Calls `tool_confirm` imported from `aelfrice.mcp_server`; the wrapper is the + business logic. No refactor of `mcp_server.py` beyond the import. +- Argparse subparser registered visible (listed in `--help`) as a user-facing + verb, consistent with `unlock` and `promote`. +- Slash command `/aelf:confirm` mirrors `src/aelfrice/slash_commands/unlock.md`. + +--- + +## Provenance refs + +- #441 — this issue (CLI port) +- #390 — shipped MCP `aelf_confirm` / `tool_confirm` +- #190 — `belief_corroborations` table (out of scope for confirm) diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index e839ad9d5..569c7eece 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -6,6 +6,7 @@ lock insert (or upgrade) a user-locked belief locked [--pressured] list locked beliefs demote manually demote a lock to none + confirm [--source S] explicitly affirm a belief (bumps Beta-Bernoulli alpha) feedback apply one Bayesian feedback event stats summary of belief / lock / history counts health structural auditor (orphan threads, FTS5 sync, locked contradictions) @@ -1178,6 +1179,40 @@ def _cmd_unlock(args: argparse.Namespace, out: object) -> int: return 0 +def _cmd_confirm(args: argparse.Namespace, out: object) -> int: + """Explicit user affirmation of a belief. Bumps Beta-Bernoulli alpha by 1.0.""" + from aelfrice.mcp_server import tool_confirm + + store = _open_store() + try: + result = tool_confirm( + store, + belief_id=args.belief_id, + source=args.source, + note=getattr(args, "note", "") or "", + ) + finally: + store.close() + + if result.get("kind") == "confirm.unknown_belief": + print(f"confirm error: {result['error']}", file=sys.stderr) + return 1 + + prior_alpha: float = result["prior_alpha"] + new_alpha: float = result["new_alpha"] + new_beta: float = result["new_beta"] + posterior_mean = new_alpha / (new_alpha + new_beta) + msg = ( + f"confirmed {args.belief_id}: " + f"alpha {prior_alpha:.3f}->{new_alpha:.3f}, " + f"mean {posterior_mean:.3f}" + ) + if result.get("note"): + msg += f" [{result['note']}]" + print(msg, file=out) # type: ignore[arg-type] + return 0 + + def _cmd_promote(args: argparse.Namespace, out: object) -> int: """Promote agent_inferred -> user_validated. Alias of `aelf validate`.""" return _cmd_validate(args, out) @@ -3274,6 +3309,27 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: p_unlock.add_argument("belief_id", help="id of the belief to unlock") p_unlock.set_defaults(func=_cmd_unlock) + # Explicit user affirmation — bumps Beta-Bernoulli alpha without freezing. + p_confirm = sub.add_parser( + "confirm", + help="affirm a belief: bumps posterior toward truth without locking it", + ) + p_confirm.add_argument("belief_id", help="id of the belief to affirm") + p_confirm.add_argument( + "--source", + default="user_confirmed", + help=( + "source label written to feedback_history. " + "Defaults to 'user_confirmed'." + ), + ) + p_confirm.add_argument( + "--note", + default="", + help="optional free-text annotation (printed on success, not persisted)", + ) + p_confirm.set_defaults(func=_cmd_confirm) + # promote: user-facing alias of validate. Same handler, same flags. p_promote = sub.add_parser( "promote", diff --git a/src/aelfrice/slash_commands/confirm.md b/src/aelfrice/slash_commands/confirm.md new file mode 100644 index 000000000..2afd8f331 --- /dev/null +++ b/src/aelfrice/slash_commands/confirm.md @@ -0,0 +1,21 @@ +--- +name: aelf:confirm +description: Affirm an existing belief — bumps the Beta-Bernoulli posterior toward truth without freezing it (use aelf:lock for ground-truth freeze). +argument-hint: The belief ID to confirm +allowed-tools: + - Bash +--- + +Explicitly affirm a belief: apply one unit of positive feedback (α += 1.0) +via the `user_confirmed` source. The belief's posterior mean moves toward 1 +without being locked. Distinct from `aelf:lock`, which freezes the belief as +ground-truth; use `confirm` when you want to nudge the posterior without the +commitment of a lock. Not persisted to `belief_corroborations` — that table +tracks duplicate re-ingests; `confirm` is an explicit user signal written to +`feedback_history`. + + + +Run: `uv run aelf confirm "$ARGUMENTS"` +Display the output verbatim. Do not add commentary. + diff --git a/tests/test_cli_confirm.py b/tests/test_cli_confirm.py new file mode 100644 index 000000000..e62c39bef --- /dev/null +++ b/tests/test_cli_confirm.py @@ -0,0 +1,196 @@ +"""Tests for `aelf confirm` CLI subcommand (#441). + +Unit tests use the in-process `main(argv=..., out=...)` harness. +Integration tests open a real MemoryStore and verify feedback_history rows. +Each test is isolated via the `isolated_db` fixture (AELFRICE_DB envvar). +""" +from __future__ import annotations + +import io +from pathlib import Path + +import pytest + +from aelfrice.cli import main +from aelfrice.models import ( + BELIEF_FACTUAL, + LOCK_NONE, + ORIGIN_AGENT_INFERRED, + Belief, +) +from aelfrice.store import MemoryStore + + +@pytest.fixture(autouse=True) +def isolated_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Every test gets its own throwaway DB at /aelf.db.""" + p = tmp_path / "aelf.db" + monkeypatch.setenv("AELFRICE_DB", str(p)) + return p + + +def _run(*argv: str) -> tuple[int, str]: + buf = io.StringIO() + code = main(argv=list(argv), out=buf) + return code, buf.getvalue() + + +def _seed_belief(db: Path, content: str, bid: str = "aabbccddeeff") -> str: + """Insert one agent_inferred belief into the store and return its id.""" + s = MemoryStore(str(db)) + try: + s.insert_belief(Belief( + id=bid, content=content, content_hash="testhash", + alpha=1.0, beta=1.0, type=BELIEF_FACTUAL, + lock_level=LOCK_NONE, locked_at=None, + demotion_pressure=0, + created_at="2026-05-05T00:00:00Z", + last_retrieved_at=None, + origin=ORIGIN_AGENT_INFERRED, + )) + finally: + s.close() + return bid + + +# --- unit: happy path ------------------------------------------------------- + + +def test_confirm_happy_path_exits_zero(isolated_db: Path) -> None: + bid = _seed_belief(isolated_db, "Python uses indentation for blocks") + code, out = _run("confirm", bid) + assert code == 0 + assert "confirmed" in out + assert bid in out + + +def test_confirm_shows_alpha_transition(isolated_db: Path) -> None: + bid = _seed_belief(isolated_db, "git commits should be atomic") + code, out = _run("confirm", bid) + assert code == 0 + # Prior alpha is 1.000; after +1.0 it should be 2.000. + assert "1.000->2.000" in out + + +def test_confirm_shows_posterior_mean(isolated_db: Path) -> None: + bid = _seed_belief(isolated_db, "always write tests before shipping") + code, out = _run("confirm", bid) + assert code == 0 + # alpha=2, beta=1 -> mean = 2/3 ≈ 0.667 + assert "mean" in out + assert "0.667" in out + + +# --- unit: unknown belief --------------------------------------------------- + + +def test_confirm_unknown_belief_exits_one(isolated_db: Path) -> None: + code, _ = _run("confirm", "doesnotexist") + assert code == 1 + + +# --- unit: --note flag ------------------------------------------------------ + + +def test_confirm_note_appears_in_output(isolated_db: Path) -> None: + bid = _seed_belief(isolated_db, "use uv for Python environment management") + code, out = _run("confirm", bid, "--note", "verified in prod") + assert code == 0 + assert "verified in prod" in out + + +def test_confirm_note_not_persisted(isolated_db: Path) -> None: + """Note must NOT be stored in feedback_history.""" + bid = _seed_belief(isolated_db, "sign every commit with SSH") + _run("confirm", bid, "--note", "check this note is gone") + s = MemoryStore(str(isolated_db)) + try: + events = s.list_feedback_events(belief_id=bid) + finally: + s.close() + assert len(events) == 1 + # There is no 'note' column in feedback_history — just verify the row exists. + assert events[0].source == "user_confirmed" + + +# --- unit: --source override ------------------------------------------------ + + +def test_confirm_source_override_written_to_history(isolated_db: Path) -> None: + bid = _seed_belief(isolated_db, "all secrets go in environment variables") + code, _ = _run("confirm", bid, "--source", "ci_automation") + assert code == 0 + s = MemoryStore(str(isolated_db)) + try: + events = s.list_feedback_events(belief_id=bid) + finally: + s.close() + assert len(events) == 1 + assert events[0].source == "ci_automation" + + +# --- integration: end-to-end ------------------------------------------------ + + +def test_confirm_writes_feedback_history_row(isolated_db: Path) -> None: + """Full integration: ingest via lock, confirm, assert row in feedback_history.""" + # Use `aelf lock` to insert a belief with a known content string. + lock_code, lock_out = _run("lock", "always review diffs before merging") + assert lock_code == 0 + + # Retrieve the belief id from the store. + s = MemoryStore(str(isolated_db)) + try: + locked = s.list_locked_beliefs() + assert len(locked) == 1 + bid = locked[0].id + pre_alpha = locked[0].alpha + pre_events = s.count_feedback_events(bid) + finally: + s.close() + + code, out = _run("confirm", bid) + assert code == 0 + assert "confirmed" in out + + s = MemoryStore(str(isolated_db)) + try: + b = s.get_belief(bid) + assert b is not None + # Alpha must have increased. + assert b.alpha > pre_alpha + # Exactly one new feedback_history row. + assert s.count_feedback_events(bid) == pre_events + 1 + events = s.list_feedback_events(belief_id=bid) + latest = events[0] + assert latest.source == "user_confirmed" + assert latest.valence == 1.0 + finally: + s.close() + + +def test_confirm_does_not_write_belief_corroborations(isolated_db: Path) -> None: + """confirm writes feedback_history, NOT belief_corroborations (#441/#190).""" + bid = _seed_belief(isolated_db, "never skip the linter") + _run("confirm", bid) + s = MemoryStore(str(isolated_db)) + try: + # belief_corroborations only populated on re-ingest dedup, not confirm. + # Verify via direct SQL; if the table doesn't exist that's also fine. + cur = s._conn.execute( # pyright: ignore[reportPrivateUsage] + "SELECT COUNT(*) FROM sqlite_master " + "WHERE type='table' AND name='belief_corroborations'" + ) + table_exists = cur.fetchone()[0] > 0 + if table_exists: + cur2 = s._conn.execute( # pyright: ignore[reportPrivateUsage] + "SELECT COUNT(*) FROM belief_corroborations WHERE belief_id = ?", + (bid,), + ) + assert cur2.fetchone()[0] == 0, ( + "confirm must not write to belief_corroborations" + ) + # Either way, feedback_history must have exactly one row. + assert s.count_feedback_events(bid) == 1 + finally: + s.close() diff --git a/tests/test_slash_commands.py b/tests/test_slash_commands.py index 0e342f1ec..f9dfd752d 100644 --- a/tests/test_slash_commands.py +++ b/tests/test_slash_commands.py @@ -42,6 +42,8 @@ # v2.0 / Track B (#389) — graph-walk surfaces. "reason", "wonder", + # v2.0 (#441) — explicit affirmation, sibling of unlock. + "confirm", )