diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c113ef9..15c8eda11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ installable release; see the roadmap in [README.md](README.md). ### Added +- **Session-end Stop hook prompts to lock session corrections** ([#582](https://github.com/robotrocketscience/aelfrice/issues/582)). New `aelf-stop-hook` (default-on) fires once per assistant-turn end, walks the store for unlocked correction-class beliefs created in the current `session_id`, and emits a `` block to stderr listing each candidate with a pre-filled `aelf lock --statement '<...>'` command. Candidate filter: `session_id == current AND lock_level != LOCK_USER AND (type == BELIEF_CORRECTION OR origin in {agent_inferred, agent_remembered})`. Hook is informational by default; setting `AELF_AUTOLOCK_CORRECTIONS=1` in the environment makes it auto-lock the candidates instead (logs each lock to stderr for transparency). Wired into `aelf setup` / `aelf unsetup` (`--no-stop-hook` opts out) and into `aelf doctor` as a fourth default-on auto-capture hook the v2.1 nag flags when missing. Coexists with the existing transcript-ingest Stop entry as a separate entry under the same `hooks.Stop` event key. **Note**: the issue's spec assumed a `aelf correct` CLI command and a `feedback_history.kind=correct` marker that don't exist on `main` (no historical implementation); the v0 detection signal is correction-class beliefs in the current session instead. Future work to ship `aelf correct` + `feedback_history.kind` would let this hook also surface beliefs that were *modified* (not only newly-created) in the session. + - **Dep-graph render workflow — Mermaid graph of open-issue Blocks/Blocked-by links** ([#581](https://github.com/robotrocketscience/aelfrice/issues/581)). New `.github/workflows/dep-graph-render.yml` (daily cron + `workflow_dispatch`) drives `scripts/dep_graph_render.py`, which pages the GraphQL `trackedIssues` / `trackedInIssues` connections for open issues matching `--label` (default `v2.1`), renders a deterministic `graph TD` block, and posts/updates a sticky comment (`` marker) on the milestone tracker (default #474). Nodes are class-coloured: red = has open blockers, green = leaf (work-ready), yellow = in-progress (any assignee or any `author-*` label). Determinism is byte-stable across runs so unchanged data produces a noop comment edit. Closes the parallel-session work-finding gap where each session had to scan all open issues to spot a leaf node. - **Session-start enrichment in `UserPromptSubmit` hook** ([#578](https://github.com/robotrocketscience/aelfrice/issues/578)). The first `UserPromptSubmit` of a new session now embeds a `` sub-block inside the `` envelope. The sub-block contains a `` section (all user-locked beliefs) and a `` section (unlocked beliefs with corroboration ≥ 2 or posterior mean ≥ ⅔ with α+β ≥ 4). Subsequent prompts in the same session carry per-turn retrieval only — no duplication. Detection: a single-file state record at `/aelfrice/session_first_prompt.json` stores the last-seen `session_id`; a changed or absent id triggers enrichment. Cost: one extra `list_locked_beliefs()` + belief-id walk per session. No new commands, no LLM calls, no filesystem walk, no new SQL. diff --git a/pyproject.toml b/pyproject.toml index c77f6bc99..58eb00063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ aelf-pre-compact-hook = "aelfrice.hook:main_pre_compact" aelf-commit-ingest = "aelfrice.hook_commit_ingest:main" aelf-search-tool-hook = "aelfrice.hook_search_tool:main" aelf-session-start-hook = "aelfrice.hook:main_session_start" +aelf-stop-hook = "aelfrice.hook:main_stop" [project.optional-dependencies] mcp = [ diff --git a/src/aelfrice/cli.py b/src/aelfrice/cli.py index 9c6e48631..1612aa6fe 100644 --- a/src/aelfrice/cli.py +++ b/src/aelfrice/cli.py @@ -134,6 +134,7 @@ SEARCH_TOOL_SCRIPT_NAME, SESSION_START_HOOK_SCRIPT_NAME, SLASH_COMMANDS_DIR_DEFAULT, # noqa: F401 — re-exported for monkeypatch in tests + STOP_HOOK_SCRIPT_NAME, SettingsScope, TRANSCRIPT_LOGGER_SCRIPT_NAME, clean_dangling_shims, @@ -146,6 +147,7 @@ install_session_start_hook, install_slash_commands, install_statusline, + install_stop_hook, install_transcript_ingest_hooks, install_user_prompt_submit_hook, resolve_commit_ingest_command, @@ -154,6 +156,7 @@ resolve_hook_command, resolve_pre_compact_hook_command, resolve_session_start_hook_command, + resolve_stop_hook_command, resolve_transcript_logger_command, uninstall_commit_ingest_hook, uninstall_search_tool_bash_hook, @@ -162,6 +165,7 @@ uninstall_session_start_hook, uninstall_slash_commands, uninstall_statusline, + uninstall_stop_hook, uninstall_transcript_ingest_hooks, uninstall_user_prompt_submit_hook, ) @@ -1869,6 +1873,24 @@ def _cmd_setup(args: argparse.Namespace, out: object) -> int: f"(command={ss_command!r})", file=out, # type: ignore[arg-type] ) + if getattr(args, "stop_hook", True): + st_command = resolve_stop_hook_command(scope) + st_result = install_stop_hook( + path, command=st_command, timeout=args.timeout, + status_message=args.status_message, + ) + if st_result.already_present: + print( + f"Stop hook already installed in {st_result.path} " + f"(command={st_command!r})", + file=out, # type: ignore[arg-type] + ) + else: + print( + f"installed Stop hook in {st_result.path} " + f"(command={st_command!r})", + file=out, # type: ignore[arg-type] + ) if not args.no_statusline: sl = install_statusline(path) if sl.mode == "installed": @@ -2107,6 +2129,21 @@ def _cmd_unsetup(args: argparse.Namespace, out: object) -> int: f"{'y' if ss_result.removed == 1 else 'ies'} from {ss_result.path}", file=out, # type: ignore[arg-type] ) + if getattr(args, "stop_hook", True): + st_result = uninstall_stop_hook( + path, command_basename=STOP_HOOK_SCRIPT_NAME, + ) + if st_result.removed == 0: + print( + f"no Stop hook in {st_result.path}", + file=out, # type: ignore[arg-type] + ) + else: + print( + f"removed {st_result.removed} Stop entr" + f"{'y' if st_result.removed == 1 else 'ies'} from {st_result.path}", + file=out, # type: ignore[arg-type] + ) if getattr(args, "transcript_ingest", True): ti_result = uninstall_transcript_ingest_hooks( path, command_basename=TRANSCRIPT_LOGGER_SCRIPT_NAME, @@ -4544,6 +4581,17 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: "Default: ON. Pass --no-session-start to skip." ), ) + p_setup.add_argument( + "--stop-hook", dest="stop_hook", + action=argparse.BooleanOptionalAction, default=True, + help=( + "wire the Stop hook so each session-end prompts to lock any " + "correction-class beliefs created in this session " + "(see #582). Coexists with the transcript-ingest Stop entry. " + "Default: ON. Pass --no-stop-hook to skip. " + "Set AELF_AUTOLOCK_CORRECTIONS=1 to auto-lock instead of prompt." + ), + ) p_setup.add_argument( "--search-tool", dest="search_tool", action="store_true", help=( @@ -4705,6 +4753,14 @@ def build_parser(*, show_advanced: bool = False) -> argparse.ArgumentParser: "Pass --no-session-start to leave it in place." ), ) + p_unsetup.add_argument( + "--stop-hook", dest="stop_hook", + action=argparse.BooleanOptionalAction, default=True, + help=( + "remove the Stop hook entry (#582). Default: ON. " + "Pass --no-stop-hook to leave it in place." + ), + ) p_unsetup.add_argument( "--search-tool", dest="search_tool", action="store_true", help="also remove the PreToolUse:Grep|Glob search-tool hook entry.", diff --git a/src/aelfrice/doctor.py b/src/aelfrice/doctor.py index a5c743a7d..2d222a48d 100644 --- a/src/aelfrice/doctor.py +++ b/src/aelfrice/doctor.py @@ -761,6 +761,9 @@ def format_report(report: DoctorReport) -> str: "aelf-transcript-logger", "aelf-commit-ingest", "aelf-session-start-hook", + # #582: session-end correction-lock prompt. Default-on since the + # Stop hook landed. + "aelf-stop-hook", ) @@ -801,9 +804,10 @@ def _format_missing_auto_capture_section( ) lines.append( "fix: re-run 'aelf setup' to wire transcript-ingest, " - "commit-ingest, and session-start (default-on since v2.1). " - "to opt out per-hook: `aelf setup --no-transcript-ingest " - "--no-commit-ingest --no-session-start`." + "commit-ingest, session-start, and stop-hook (default-on " + "since v2.1 / #582). to opt out per-hook: " + "`aelf setup --no-transcript-ingest --no-commit-ingest " + "--no-session-start --no-stop-hook`." ) diff --git a/src/aelfrice/hook.py b/src/aelfrice/hook.py index 0b4f03770..db6a41ba9 100644 --- a/src/aelfrice/hook.py +++ b/src/aelfrice/hook.py @@ -49,7 +49,15 @@ rebuild_v14, ) from aelfrice.hook_search import search_for_prompt - from aelfrice.models import LOCK_NONE, LOCK_USER, Belief + from aelfrice.models import ( + BELIEF_CORRECTION, + LOCK_NONE, + LOCK_USER, + ORIGIN_AGENT_INFERRED, + ORIGIN_AGENT_REMEMBERED, + ORIGIN_USER_STATED, + Belief, + ) from aelfrice.retrieval import retrieve from aelfrice.store import MemoryStore @@ -1461,6 +1469,211 @@ def _format_baseline_hits(hits: list[Belief]) -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Stop hook — session-end correction-lock prompt (#582) +# --------------------------------------------------------------------------- + +AUTOLOCK_ENV_VAR: Final[str] = "AELF_AUTOLOCK_CORRECTIONS" +"""When set to a truthy value (1/true/yes/on, case-insensitive), the Stop +hook auto-locks every session-scoped correction candidate it finds and +logs each lock to stderr instead of printing the prompt. Default off: +locking is meaning-bearing and should not happen silently.""" + +STOP_PROMPT_OPEN_TAG: Final[str] = "" +STOP_PROMPT_CLOSE_TAG: Final[str] = "" + +# Origins that flag a belief as a candidate for end-of-session lock prompt. +# Mirrors the issue #582 design: agent-paraphrased corrections never +# survive context resets unless promoted to user-asserted ground truth. +_STOP_PROMPT_AGENT_ORIGINS: Final[frozenset[str]] = frozenset({ + ORIGIN_AGENT_INFERRED, + ORIGIN_AGENT_REMEMBERED, +}) + + +def _autolock_enabled(env: dict[str, str] | None = None) -> bool: + """Return True when the AELF_AUTOLOCK_CORRECTIONS env var is truthy.""" + src = env if env is not None else os.environ + val = src.get(AUTOLOCK_ENV_VAR, "").strip().lower() + return val in {"1", "true", "yes", "on"} + + +def _belief_is_lock_candidate(b: "Belief", session_id: str) -> bool: + """Return True iff `b` is a session-scoped, unlocked correction-class + belief — the population the Stop hook prompts the user to lock. + + Conditions: + * `b.session_id == session_id` (created in this session). + * `b.lock_level != LOCK_USER` (locking would be a no-op otherwise). + * `b.type == BELIEF_CORRECTION` OR `b.origin in + {agent_inferred, agent_remembered}` (correction-class signal). + """ + if b.session_id != session_id: + return False + if b.lock_level == LOCK_USER: + return False + if b.type == BELIEF_CORRECTION: + return True + if b.origin in _STOP_PROMPT_AGENT_ORIGINS: + return True + return False + + +def _collect_lock_candidates( + store: "MemoryStore", session_id: str +) -> list["Belief"]: + """Walk all beliefs once and return the lock-prompt candidates. + + Cost: one `list_belief_ids()` + one `get_belief()` per id. For small + stores (<1k beliefs, the typical case at session-end) this is sub-100ms. + A focused SQL query is a future optimisation when stores grow. + """ + candidates: list[Belief] = [] + for bid in store.list_belief_ids(): + b = store.get_belief(bid) + if b is None: + continue + if _belief_is_lock_candidate(b, session_id): + candidates.append(b) + return candidates + + +def _format_stop_prompt(candidates: list["Belief"]) -> str: + """Render the stderr block listing each candidate with a pre-filled + `aelf lock` command. Empty list → empty string.""" + if not candidates: + return "" + n = len(candidates) + plural = "correction" if n == 1 else "corrections" + lines: list[str] = [ + STOP_PROMPT_OPEN_TAG, + f"Found {n} {plural} in this session that aren't locked.", + "Run the suggested commands to make them survive into the next session,", + "or set AELF_AUTOLOCK_CORRECTIONS=1 to auto-lock corrections at session end.", + "", + ] + for b in candidates: + snippet = b.content.strip().replace("\n", " ") + if len(snippet) > 120: + snippet = snippet[:117] + "..." + lines.append(f" - {b.id} ({b.type}, origin={b.origin}): {snippet}") + lines.append(f" aelf lock --statement {_shell_quote(b.content)}") + lines.append(STOP_PROMPT_CLOSE_TAG) + lines.append("") + return "\n".join(lines) + + +def _shell_quote(s: str) -> str: + """Single-quote `s` for safe paste into a shell. Escapes embedded single + quotes by closing/escaping/reopening, matching POSIX shell semantics.""" + return "'" + s.replace("'", "'\\''") + "'" + + +def _autolock_candidates( + store: "MemoryStore", candidates: list["Belief"], stderr: IO[str] +) -> int: + """Upgrade every candidate's lock_level to LOCK_USER in place. Returns + the count actually locked. Mirrors the re-lock-upgrade path from + `_cmd_lock` (cli.py) without going through the derivation worker — + these beliefs already exist; only the lock fields change.""" + now = _utc_now_iso() + locked = 0 + for b in candidates: + try: + b.lock_level = LOCK_USER + b.locked_at = now + b.demotion_pressure = 0 + b.origin = ORIGIN_USER_STATED + store.update_belief(b) + locked += 1 + print( + f"aelfrice: auto-locked {b.id} ({b.type}, origin→user_stated)", + file=stderr, + ) + except Exception as exc: + print( + f"aelfrice: auto-lock failed for {b.id}: {exc}", + file=stderr, + ) + return locked + + +def _utc_now_iso() -> str: + """ISO-8601 UTC timestamp; matches the format used by other hook + helpers without importing cli (which would create a circular import).""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def stop( + *, + stdin: IO[str] | None = None, + stdout: IO[str] | None = None, + stderr: IO[str] | None = None, + env: dict[str, str] | None = None, +) -> int: + """Run the Stop hook. Always returns 0. + + Reads a Stop JSON payload from `stdin` (harness contract — same + payload shape as the SessionStart and PreCompact handlers above), + finds all correction-class beliefs created in this session that + aren't yet user-locked, and either emits a stderr listing with + pre-filled `aelf lock` commands (default) or auto-locks them when + `AELF_AUTOLOCK_CORRECTIONS=1` is set in the environment. + + Hook contract: never block, never raise. Empty / malformed payload, + missing session_id, no candidates, store errors — all return 0 + silently (or with a single stderr line for visibility). + + The Stop event fires once per assistant-turn end (harness-defined). + The hook is therefore on the post-turn fan-out path and must stay + cheap; the candidate-walk is bounded by store size. + """ + sin = stdin if stdin is not None else sys.stdin + serr = stderr if stderr is not None else sys.stderr + if not _IMPORTS_OK: + return 0 + try: + raw = sin.read() + if not raw or not raw.strip(): + return 0 + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return 0 + if not isinstance(payload, dict): + return 0 + session_id = _extract_session_id(raw) + if not session_id: + return 0 + try: + store = _open_store() + except Exception: + return 0 + try: + candidates = _collect_lock_candidates(store, session_id) + if not candidates: + return 0 + if _autolock_enabled(env): + _autolock_candidates(store, candidates, serr) + return 0 + block = _format_stop_prompt(candidates) + if block: + # stderr per the Stop-hook contract: any prompt-shaped + # output to the human reading the session must go to stderr, + # not stdout (Stop has no additionalContext channel). + serr.write(block) + finally: + store.close() + except Exception as exc: + # Last-resort fail-soft. Surface to stderr so the hook log shows + # the trace; never bubble to the harness. + print( + f"aelfrice: stop hook unexpected error (non-fatal): {exc}", + file=serr, + ) + return 0 + + def main() -> int: """Entry point for `python -m aelfrice.hook`.""" return user_prompt_submit() @@ -1476,5 +1689,10 @@ def main_session_start() -> int: return session_start() +def main_stop() -> int: + """Entry point for the Stop hook console script (#582).""" + return stop() + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/aelfrice/setup.py b/src/aelfrice/setup.py index 4788499a9..c7134cc57 100644 --- a/src/aelfrice/setup.py +++ b/src/aelfrice/setup.py @@ -437,6 +437,12 @@ def _uninstall_event_hook( SESSION_START_EVENT_KEY: Final[str] = "SessionStart" SESSION_START_HOOK_SCRIPT_NAME: Final[str] = "aelf-session-start-hook" +# #582: separate Stop entry for the session-end correction-lock prompt. +# Distinct from aelf-transcript-logger which already wires onto Stop; +# the two coexist as separate entries under the Stop event key. +STOP_EVENT_KEY: Final[str] = "Stop" +STOP_HOOK_SCRIPT_NAME: Final[str] = "aelf-stop-hook" + def resolve_transcript_logger_command(scope: SettingsScope) -> str: """Pick the absolute aelf-transcript-logger path for `scope`. @@ -942,6 +948,109 @@ def uninstall_session_start_hook( return UninstallResult(path=settings_path, removed=removed) +# --- Stop hook wiring (#582) ------------------------------------------ + + +def resolve_stop_hook_command(scope: SettingsScope) -> str: + """Pick the absolute aelf-stop-hook path for `scope`. + + Same routing primitive as resolve_session_start_hook_command: project + scope pins to the venv next to sys.executable; user scope prefers + $PATH. + """ + venv_bin = _venv_bin_dir() + venv_hook = _executable_in_dir(venv_bin, STOP_HOOK_SCRIPT_NAME) + path_hook_str = shutil.which(STOP_HOOK_SCRIPT_NAME) + path_hook = Path(path_hook_str) if path_hook_str else None + if scope == "project": + chosen = venv_hook or path_hook + else: + chosen = path_hook or venv_hook + if chosen is None: + return STOP_HOOK_SCRIPT_NAME + return str(chosen) + + +def install_stop_hook( + settings_path: Path, + *, + command: str, + timeout: int | None = None, + status_message: str | None = None, +) -> InstallResult: + """Add a Stop hook entry running `command`. Idempotent. + + Stop fires once per assistant-turn end. The aelfrice handler + enumerates correction-class beliefs created in the current session + that aren't yet user-locked and prompts the user to lock them so + they survive context resets (#582). + + Coexists with the transcript-ingest Stop entry (which appends + assistant-turn rows to turns.jsonl); the two are separate entries + under the same Stop event key and never disturb each other. + """ + if not command: + raise ValueError("command must be a non-empty string") + data = _load_settings(settings_path) + entries = _get_event_list(data, STOP_EVENT_KEY, create=True) + if _find_entry_index(entries, command) is not None: + return InstallResult( + path=settings_path, installed=False, already_present=True + ) + entries.append( + _build_entry( + command=command, timeout=timeout, status_message=status_message + ) + ) + _atomic_write(settings_path, data) + return InstallResult( + path=settings_path, installed=True, already_present=False + ) + + +def uninstall_stop_hook( + settings_path: Path, + *, + command: str | None = None, + command_basename: str | None = None, +) -> UninstallResult: + """Remove Stop entries matching `command` or `command_basename`. + + Same exact/basename match semantics as + uninstall_session_start_hook. Returns removed=0 if the file does + not exist or has no matching entry. + """ + if command is None and command_basename is None: + raise ValueError("provide command or command_basename") + if command is not None and command_basename is not None: + raise ValueError("command and command_basename are mutually exclusive") + if command is not None and not command: + raise ValueError("command must be a non-empty string") + if command_basename is not None and not command_basename: + raise ValueError("command_basename must be a non-empty string") + if not settings_path.exists(): + return UninstallResult(path=settings_path, removed=0) + data = _load_settings(settings_path) + entries = _get_event_list(data, STOP_EVENT_KEY, create=False) + if entries is None: + return UninstallResult(path=settings_path, removed=0) + before = len(entries) + if command is not None: + kept = [e for e in entries if not _entry_matches(e, command)] + else: + assert command_basename is not None + kept = [ + e for e in entries + if not _entry_matches_basename(e, command_basename) + ] + removed = before - len(kept) + if removed == 0: + return UninstallResult(path=settings_path, removed=0) + entries[:] = kept + _atomic_write(settings_path, data) + return UninstallResult(path=settings_path, removed=removed) + + # --- Statusline auto-wiring --------------------------------------------- diff --git a/tests/test_cli_setup.py b/tests/test_cli_setup.py index 241c76f52..ec5ec569a 100644 --- a/tests/test_cli_setup.py +++ b/tests/test_cli_setup.py @@ -54,6 +54,7 @@ def _project_settings(tmp_path: Path) -> Path: "--no-transcript-ingest", "--no-commit-ingest", "--no-session-start", + "--no-stop-hook", ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 74fd9b195..b3b20453d 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -359,6 +359,7 @@ def test_diagnose_flags_missing_auto_capture_hooks_when_pre_v21( "aelf-transcript-logger", "aelf-commit-ingest", "aelf-session-start-hook", + "aelf-stop-hook", ] rendered = format_report(report) assert "auto-capture hooks not installed" in rendered @@ -368,13 +369,14 @@ def test_diagnose_flags_missing_auto_capture_hooks_when_pre_v21( def test_diagnose_quiet_when_all_auto_capture_hooks_present( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Fresh v2.1 install: all three default-on hooks present. No nag - line in the rendered report.""" + """Fresh v2.1+ install: all default-on hooks present (including the + #582 stop hook). No nag line in the rendered report.""" bin_dir = tmp_path / "bin" retrieval = _exec(bin_dir / "aelf-hook") transcript = _exec(bin_dir / "aelf-transcript-logger") commit_ingest = _exec(bin_dir / "aelf-commit-ingest") session_start = _exec(bin_dir / "aelf-session-start-hook") + stop_hook = _exec(bin_dir / "aelf-stop-hook") user_path = tmp_path / "settings.json" _write_settings(user_path, { "hooks": { @@ -384,6 +386,7 @@ def test_diagnose_quiet_when_all_auto_capture_hooks_present( ], "Stop": [ {"hooks": [{"type": "command", "command": str(transcript)}]}, + {"hooks": [{"type": "command", "command": str(stop_hook)}]}, ], "PreCompact": [ {"hooks": [{"type": "command", "command": str(transcript)}]}, @@ -431,6 +434,7 @@ def test_diagnose_partial_auto_capture_lists_only_missing( assert report.missing_auto_capture_hooks == [ "aelf-commit-ingest", "aelf-session-start-hook", + "aelf-stop-hook", ] @@ -455,4 +459,5 @@ def test_auto_capture_basenames_match_setup() -> None: setup.TRANSCRIPT_LOGGER_SCRIPT_NAME, setup.COMMIT_INGEST_SCRIPT_NAME, setup.SESSION_START_HOOK_SCRIPT_NAME, + setup.STOP_HOOK_SCRIPT_NAME, } diff --git a/tests/test_hook_stop_lock_prompt.py b/tests/test_hook_stop_lock_prompt.py new file mode 100644 index 000000000..8fb3398ee --- /dev/null +++ b/tests/test_hook_stop_lock_prompt.py @@ -0,0 +1,363 @@ +"""Stop hook session-end correction-lock prompt tests (#582). + +Verifies: + - `_belief_is_lock_candidate` filter rules (session, lock_level, + type/origin gating). + - `_collect_lock_candidates` walks all beliefs and returns only the + matches. + - `_format_stop_prompt` renders the stderr block with the + `aelf lock` pre-fills and proper plural/empty handling. + - `_autolock_enabled` env-var parsing. + - `_autolock_candidates` mutates lock_level in place + writes back. + - `stop()` end-to-end: empty / malformed / missing-session-id + payloads return 0 and emit nothing; candidate payloads emit the + block on stderr; AUTOLOCK env var auto-locks instead of prompting. +""" +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from aelfrice.hook import ( + AUTOLOCK_ENV_VAR, + STOP_PROMPT_CLOSE_TAG, + STOP_PROMPT_OPEN_TAG, + _autolock_candidates, + _autolock_enabled, + _belief_is_lock_candidate, + _collect_lock_candidates, + _format_stop_prompt, + stop, +) +from aelfrice.models import ( + BELIEF_CORRECTION, + BELIEF_FACTUAL, + LOCK_NONE, + LOCK_USER, + ORIGIN_AGENT_INFERRED, + ORIGIN_AGENT_REMEMBERED, + ORIGIN_USER_STATED, + Belief, +) +from aelfrice.store import MemoryStore + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mk( + bid: str, + content: str, + *, + session_id: str | None = "sess-A", + lock_level: str = LOCK_NONE, + type_: str = BELIEF_FACTUAL, + origin: str = "unknown", +) -> Belief: + return Belief( + id=bid, + content=content, + content_hash=f"h_{bid}", + alpha=1.0, + beta=1.0, + type=type_, + lock_level=lock_level, + locked_at=None, + demotion_pressure=0, + created_at="2026-01-01T00:00:00Z", + last_retrieved_at=None, + session_id=session_id, + origin=origin, + ) + + +def _seed(db: Path, beliefs: list[Belief]) -> None: + s = MemoryStore(str(db)) + try: + for b in beliefs: + s.insert_belief(b) + finally: + s.close() + + +def _set_db(monkeypatch: pytest.MonkeyPatch, db: Path) -> None: + monkeypatch.setenv("AELFRICE_DB", str(db)) + + +def _payload(session_id: str | None) -> str: + obj: dict[str, object] = {"cwd": "/tmp"} + if session_id is not None: + obj["session_id"] = session_id + return json.dumps(obj) + + +# --------------------------------------------------------------------------- +# _belief_is_lock_candidate +# --------------------------------------------------------------------------- + + +def test_candidate_correction_type_unlocked_in_session() -> None: + b = _mk("B1", "x", type_=BELIEF_CORRECTION, session_id="sess-A") + assert _belief_is_lock_candidate(b, "sess-A") is True + + +def test_candidate_agent_inferred_origin_in_session() -> None: + b = _mk("B2", "x", origin=ORIGIN_AGENT_INFERRED, session_id="sess-A") + assert _belief_is_lock_candidate(b, "sess-A") is True + + +def test_candidate_agent_remembered_origin_in_session() -> None: + b = _mk("B3", "x", origin=ORIGIN_AGENT_REMEMBERED, session_id="sess-A") + assert _belief_is_lock_candidate(b, "sess-A") is True + + +def test_not_candidate_when_locked_user() -> None: + b = _mk("B4", "x", type_=BELIEF_CORRECTION, + lock_level=LOCK_USER, session_id="sess-A") + assert _belief_is_lock_candidate(b, "sess-A") is False + + +def test_not_candidate_other_session() -> None: + b = _mk("B5", "x", type_=BELIEF_CORRECTION, session_id="sess-OTHER") + assert _belief_is_lock_candidate(b, "sess-A") is False + + +def test_not_candidate_no_session_id_on_belief() -> None: + b = _mk("B6", "x", type_=BELIEF_CORRECTION, session_id=None) + assert _belief_is_lock_candidate(b, "sess-A") is False + + +def test_not_candidate_factual_user_origin() -> None: + b = _mk("B7", "x", type_=BELIEF_FACTUAL, origin=ORIGIN_USER_STATED, + session_id="sess-A") + assert _belief_is_lock_candidate(b, "sess-A") is False + + +# --------------------------------------------------------------------------- +# _collect_lock_candidates +# --------------------------------------------------------------------------- + + +def test_collect_returns_only_session_unlocked_correction_class( + tmp_path: Path, +) -> None: + db = tmp_path / "memory.db" + _seed(db, [ + _mk("KEEP1", "fix one", type_=BELIEF_CORRECTION, session_id="sess-A"), + _mk("KEEP2", "fix two", origin=ORIGIN_AGENT_INFERRED, + session_id="sess-A"), + _mk("SKIP_OTHER_SESSION", "x", type_=BELIEF_CORRECTION, + session_id="sess-B"), + _mk("SKIP_LOCKED", "x", type_=BELIEF_CORRECTION, + lock_level=LOCK_USER, session_id="sess-A"), + _mk("SKIP_FACTUAL_USER", "x", type_=BELIEF_FACTUAL, + origin=ORIGIN_USER_STATED, session_id="sess-A"), + ]) + s = MemoryStore(str(db)) + try: + cands = _collect_lock_candidates(s, "sess-A") + finally: + s.close() + ids = {c.id for c in cands} + assert ids == {"KEEP1", "KEEP2"} + + +def test_collect_empty_store_returns_empty(tmp_path: Path) -> None: + db = tmp_path / "memory.db" + s = MemoryStore(str(db)) + try: + assert _collect_lock_candidates(s, "sess-A") == [] + finally: + s.close() + + +# --------------------------------------------------------------------------- +# _format_stop_prompt +# --------------------------------------------------------------------------- + + +def test_format_empty_returns_empty() -> None: + assert _format_stop_prompt([]) == "" + + +def test_format_has_open_close_tags_and_lock_command() -> None: + b = _mk("B1", "atomic commits beat batched", + type_=BELIEF_CORRECTION, session_id="sess-A") + block = _format_stop_prompt([b]) + assert STOP_PROMPT_OPEN_TAG in block + assert STOP_PROMPT_CLOSE_TAG in block + assert "B1" in block + assert "atomic commits beat batched" in block + assert "aelf lock --statement" in block + + +def test_format_pluralizes_correctly() -> None: + one = _format_stop_prompt( + [_mk("B1", "x", type_=BELIEF_CORRECTION)]) + many = _format_stop_prompt([ + _mk("B1", "x", type_=BELIEF_CORRECTION), + _mk("B2", "y", type_=BELIEF_CORRECTION), + ]) + assert "1 correction" in one + assert "2 corrections" in many + + +def test_format_truncates_long_content() -> None: + long = "x" * 300 + block = _format_stop_prompt( + [_mk("BL", long, type_=BELIEF_CORRECTION)]) + # Snippet capped at ~120 chars + ellipsis; full content still in + # the lock command for the user to inspect. + assert "..." in block + + +# --------------------------------------------------------------------------- +# _autolock_enabled env-var parsing +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("v", ["1", "true", "True", "YES", "on"]) +def test_autolock_enabled_truthy(v: str) -> None: + assert _autolock_enabled({AUTOLOCK_ENV_VAR: v}) is True + + +@pytest.mark.parametrize("v", ["", "0", "false", "no", "off", "maybe"]) +def test_autolock_disabled_falsy(v: str) -> None: + assert _autolock_enabled({AUTOLOCK_ENV_VAR: v}) is False + + +def test_autolock_disabled_missing_env() -> None: + assert _autolock_enabled({}) is False + + +# --------------------------------------------------------------------------- +# _autolock_candidates +# --------------------------------------------------------------------------- + + +def test_autolock_candidates_mutates_in_place(tmp_path: Path) -> None: + db = tmp_path / "memory.db" + _seed(db, [ + _mk("L1", "fix", type_=BELIEF_CORRECTION, session_id="sess-A"), + ]) + s = MemoryStore(str(db)) + try: + cands = _collect_lock_candidates(s, "sess-A") + n = _autolock_candidates(s, cands, io.StringIO()) + assert n == 1 + # Re-read to confirm the persisted state. + b = s.get_belief("L1") + assert b is not None + assert b.lock_level == LOCK_USER + assert b.origin == ORIGIN_USER_STATED + assert b.locked_at is not None + assert b.demotion_pressure == 0 + finally: + s.close() + + +# --------------------------------------------------------------------------- +# stop() integration +# --------------------------------------------------------------------------- + + +def test_stop_empty_stdin_returns_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_db(monkeypatch, tmp_path / "memory.db") + rc = stop(stdin=io.StringIO(""), stdout=io.StringIO(), + stderr=io.StringIO()) + assert rc == 0 + + +def test_stop_malformed_json_returns_zero( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _set_db(monkeypatch, tmp_path / "memory.db") + rc = stop(stdin=io.StringIO("not json"), + stdout=io.StringIO(), stderr=io.StringIO()) + assert rc == 0 + + +def test_stop_missing_session_id_returns_zero_no_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "memory.db" + _seed(db, [_mk("B1", "x", type_=BELIEF_CORRECTION, session_id="sess-A")]) + _set_db(monkeypatch, db) + serr = io.StringIO() + rc = stop(stdin=io.StringIO(_payload(None)), + stdout=io.StringIO(), stderr=serr) + assert rc == 0 + assert STOP_PROMPT_OPEN_TAG not in serr.getvalue() + + +def test_stop_no_candidates_emits_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "memory.db" + _seed(db, [_mk("B1", "x", type_=BELIEF_FACTUAL, + origin=ORIGIN_USER_STATED, session_id="sess-A")]) + _set_db(monkeypatch, db) + serr = io.StringIO() + rc = stop(stdin=io.StringIO(_payload("sess-A")), + stdout=io.StringIO(), stderr=serr) + assert rc == 0 + assert serr.getvalue() == "" + + +def test_stop_emits_lock_prompt_on_stderr( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "memory.db" + _seed(db, [_mk("B1", "use uv not pip", + type_=BELIEF_CORRECTION, session_id="sess-A")]) + _set_db(monkeypatch, db) + serr = io.StringIO() + rc = stop(stdin=io.StringIO(_payload("sess-A")), + stdout=io.StringIO(), stderr=serr, + env={}) # explicitly no AUTOLOCK + assert rc == 0 + out = serr.getvalue() + assert STOP_PROMPT_OPEN_TAG in out + assert "use uv not pip" in out + assert "aelf lock --statement" in out + # Belief should NOT be locked yet — prompt mode is informational only. + s = MemoryStore(str(db)) + try: + b = s.get_belief("B1") + assert b is not None + assert b.lock_level == LOCK_NONE + finally: + s.close() + + +def test_stop_autolock_env_locks_and_suppresses_prompt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = tmp_path / "memory.db" + _seed(db, [_mk("B1", "use uv not pip", + type_=BELIEF_CORRECTION, session_id="sess-A")]) + _set_db(monkeypatch, db) + serr = io.StringIO() + rc = stop(stdin=io.StringIO(_payload("sess-A")), + stdout=io.StringIO(), stderr=serr, + env={AUTOLOCK_ENV_VAR: "1"}) + assert rc == 0 + out = serr.getvalue() + # No lock-prompt block — autolock path emits a one-liner per locked belief + assert STOP_PROMPT_OPEN_TAG not in out + assert "auto-locked B1" in out + s = MemoryStore(str(db)) + try: + b = s.get_belief("B1") + assert b is not None + assert b.lock_level == LOCK_USER + assert b.origin == ORIGIN_USER_STATED + finally: + s.close() diff --git a/tests/test_setup_stop_hook.py b/tests/test_setup_stop_hook.py new file mode 100644 index 000000000..3133ed352 --- /dev/null +++ b/tests/test_setup_stop_hook.py @@ -0,0 +1,143 @@ +"""install_stop_hook / uninstall_stop_hook idempotency (#582). + +Mirrors test_setup_session_start.py one-for-one against the Stop event. +""" +from __future__ import annotations + +import json +from pathlib import Path + +from aelfrice.setup import ( + install_stop_hook, + install_transcript_ingest_hooks, + install_user_prompt_submit_hook, + uninstall_stop_hook, +) + + +_STOP_CMD = "/usr/local/bin/aelf-stop-hook" +_TI_CMD = "/usr/local/bin/aelf-transcript-logger" +_UPS_CMD = "/usr/local/bin/aelf-hook" + + +def _read(path: Path) -> dict[str, object]: + return json.loads(path.read_text()) + + +# ---- install ----------------------------------------------------------- + + +def test_install_stop_hook_writes_entry(tmp_path: Path) -> None: + p = tmp_path / "settings.json" + result = install_stop_hook(p, command=_STOP_CMD) + assert result.installed is True + assert result.already_present is False + data = _read(p) + hooks = data["hooks"] # type: ignore[index] + assert "Stop" in hooks + entry_list = hooks["Stop"] # type: ignore[index] + assert isinstance(entry_list, list) and len(entry_list) == 1 + inner = entry_list[0]["hooks"][0] + assert inner["type"] == "command" + assert inner["command"] == _STOP_CMD + + +def test_install_stop_hook_idempotent(tmp_path: Path) -> None: + p = tmp_path / "settings.json" + install_stop_hook(p, command=_STOP_CMD) + second = install_stop_hook(p, command=_STOP_CMD) + assert second.installed is False + assert second.already_present is True + data = _read(p) + entry_list = data["hooks"]["Stop"] # type: ignore[index] + assert len(entry_list) == 1 + + +def test_install_stop_hook_coexists_with_transcript_ingest_stop( + tmp_path: Path, +) -> None: + """Both Stop entries (transcript-logger + stop-hook) must live + independently under the same Stop event key. Critical: the + transcript-ingest install must not be displaced when the stop-hook + install runs (and vice versa).""" + p = tmp_path / "settings.json" + install_transcript_ingest_hooks(p, command=_TI_CMD) + install_stop_hook(p, command=_STOP_CMD) + data = _read(p) + entries = data["hooks"]["Stop"] # type: ignore[index] + commands = [e["hooks"][0]["command"] for e in entries] + assert _TI_CMD in commands + assert _STOP_CMD in commands + assert len(entries) == 2 + + +def test_install_stop_hook_does_not_touch_other_events( + tmp_path: Path, +) -> None: + p = tmp_path / "settings.json" + install_user_prompt_submit_hook(p, command=_UPS_CMD) + install_stop_hook(p, command=_STOP_CMD) + data = _read(p) + hooks = data["hooks"] # type: ignore[index] + assert "UserPromptSubmit" in hooks + assert "Stop" in hooks + assert len(hooks["UserPromptSubmit"]) == 1 + assert len(hooks["Stop"]) == 1 + + +# ---- uninstall --------------------------------------------------------- + + +def test_uninstall_stop_hook_removes_entry(tmp_path: Path) -> None: + p = tmp_path / "settings.json" + install_stop_hook(p, command=_STOP_CMD) + result = uninstall_stop_hook(p, command=_STOP_CMD) + assert result.removed == 1 + data = _read(p) + assert data["hooks"]["Stop"] == [] # type: ignore[index] + + +def test_uninstall_stop_hook_no_match(tmp_path: Path) -> None: + p = tmp_path / "settings.json" + install_stop_hook(p, command=_STOP_CMD) + result = uninstall_stop_hook(p, command="/different/binary") + assert result.removed == 0 + + +def test_uninstall_stop_hook_missing_file(tmp_path: Path) -> None: + p = tmp_path / "nope.json" + result = uninstall_stop_hook(p, command=_STOP_CMD) + assert result.removed == 0 + + +def test_uninstall_stop_hook_basename_does_not_remove_transcript_ingest( + tmp_path: Path, +) -> None: + """Basename uninstall of stop-hook must NOT match the transcript-logger + entry under the same Stop key — the two share the event but have + distinct basenames.""" + p = tmp_path / "settings.json" + install_transcript_ingest_hooks(p, command=_TI_CMD) + install_stop_hook(p, command=_STOP_CMD) + result = uninstall_stop_hook(p, command_basename="aelf-stop-hook") + assert result.removed == 1 + data = _read(p) + remaining = data["hooks"]["Stop"] # type: ignore[index] + assert len(remaining) == 1 + assert remaining[0]["hooks"][0]["command"] == _TI_CMD + + +def test_uninstall_stop_hook_by_basename(tmp_path: Path) -> None: + p = tmp_path / "settings.json" + install_stop_hook(p, command=_STOP_CMD) + install_stop_hook( + p, command="/different/path/aelf-stop-hook" + ) + data = _read(p) + assert len(data["hooks"]["Stop"]) == 2 # type: ignore[index] + result = uninstall_stop_hook( + p, command_basename="aelf-stop-hook" + ) + assert result.removed == 2 + data = _read(p) + assert data["hooks"]["Stop"] == [] # type: ignore[index]