Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<aelfrice-session-end>` 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 (`<!-- dep-graph-auto -->` 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 `<session-start>` sub-block inside the `<aelfrice-memory>` envelope. The sub-block contains a `<locked>` section (all user-locked beliefs) and a `<core>` 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 `<git-common-dir>/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.

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
56 changes: 56 additions & 0 deletions src/aelfrice/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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.",
Expand Down
10 changes: 7 additions & 3 deletions src/aelfrice/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand Down Expand Up @@ -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`."
)


Expand Down
220 changes: 219 additions & 1 deletion src/aelfrice/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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] = "<aelfrice-session-end>"
STOP_PROMPT_CLOSE_TAG: Final[str] = "</aelfrice-session-end>"

# 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,
Comment on lines +1582 to +1591

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (performance): Avoid parsing the Stop payload JSON if the parsed object is never used

payload = json.loads(raw) is never used; the rest of the function works directly with raw and _extract_session_id(raw). This adds unnecessary overhead on a hot path and could cause behavior drift if _extract_session_id later expects different input. Either remove the parse or change the code to use payload (for example, by having _extract_session_id take the parsed dict).

)
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()
Expand All @@ -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())
Loading
Loading