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
3 changes: 3 additions & 0 deletions CHANGELOG/v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Phantom promotion-opportunity detector ([#1132](https://github.com/robotrocketscience/aelfrice/issues/1132)).** The promotion-side mirror of the #980 phantom-*generation* detector. On each `UserPromptSubmit` turn, when the opt-in `[phantom_promotion]` lane is enabled, aelfrice deterministically checks whether any phantom (`origin='speculative'`) has crossed a cross-session corroboration threshold (default ≥3 corroborations across ≥2 distinct sessions, no inbound CONTRADICTS) and, if so, appends a small `<aelfrice-phantom-promotion-opportunity>` note naming the candidate(s) and their `aelf validate <id>` / `aelf lock` surface. This closes the #1125-census gap — phantoms were essentially never promoted (0 across seven real stores) not because promotion is broken but because nothing surfaced a corroborated phantom for the explicit act the ratified #229 rule requires. **Note-not-write:** origin promotion stays exactly where #229 put it (an explicit user act; a corroboration count is a *non-trigger* for the write); this lane only decides *when to prompt*, never promotes autonomously. New pure `aelfrice.phantom_promotion_opportunity` module (config resolution mirroring `[phantom_generation]`, deterministic `store.find_promotable_phantoms` detector, per-session budget + belief-id dedup in `session_ring` state, XML-escaped note) and a fail-soft `UserPromptSubmit` lane. Default-off; inert until enabled. No clock, no network, no LLM (#605-clean).
- **Keyword-triggered belief categories ([#1126](https://github.com/robotrocketscience/aelfrice/issues/1126)).** Group beliefs into named categories (repo-rules, git-workflow, prose-and-docs, …) and bind each to an activation trigger; when a category fires — it is always-on, or one of its keyword phrases appears in the prompt — the `UserPromptSubmit` hook **reranks the retrieval output** so that category's rules lead the `<aelfrice-memory>` block, prepends a one-line `<category-focus>` label, and surfaces a bounded set of members retrieval missed. The conditional, right-rule-at-the-right-moment complement to a static `CLAUDE.md`/`AGENTS.md`. It **reranks rather than injecting a second block**: R&D on the feature showed a separate block double-injects what retrieval (L0 + BM25) already returns and that category members are almost always already in the retrieval tail, so the value is prioritising + labelling the one block, not adding content. **Advisory, not enforcement** — it never blocks a tool call (per the #199 enforcement history and the fail-open hook contract); a hard-block lane is an explicit non-goal. Two additive tables (`categories` with a `trigger_json` config blob + a `belief_categories` M2M join modeled on `belief_documents`) — no `Belief` column, and additive DDL that passes the destructive-only migration-policy gate. New pure `aelfrice.category` module: deterministic keyword (case-insensitive, word-boundary, whitespace-tolerant) and cross-platform `fnmatch` glob matchers, stable trigger (de)serialization, and the default-off `is_enabled` gate (env `AELFRICE_BELIEF_CATEGORIES` > `[belief_categories] enabled` > `false`) — no embeddings, no model call, no clock (#605-clean). New `aelf category` verb (`init`/`add`/`list`/`show`/`set-trigger`/`assign`/`unassign`/`delete`) and `/aelf:category` slash command; assign at lock time with `aelf lock "<rule>" --category <name>`. `aelf category init` seeds a 5-category starter set. Membership is user-driven — no auto-classification. The tool/file trigger lanes are stored + matched but their `PreToolUse` wiring is a follow-up. Fail-soft throughout: any error leaves the retrieval hits unchanged and the hook returns exit 0.

### Changed

### Fixed

- **Corroboration-driven retention promotion no longer reaches phantoms ([#1132](https://github.com/robotrocketscience/aelfrice/issues/1132)).** `store.find_promotable_snapshots` (the `aelf doctor --promote-retention` path that flips `retention_class` snapshot→fact once a belief is corroborated ≥3× across ≥2 sessions) filtered on `retention_class` but not origin. Phantoms ingest with `retention_class='snapshot'`, so a corroborated phantom was eligible for a count-driven flip to durable `fact` retention — the exact trigger shape the ratified #229 rule rejects on the origin axis. Added `AND origin != 'speculative'` so phantom durability, like phantom trust promotion, advances only through explicit acknowledgment. Retention and origin remain orthogonal axes; neither promotes a speculative belief on a corroboration count alone.

## [4.1.0] - 2026-07-09

### Added
Expand Down
20 changes: 20 additions & 0 deletions docs/user/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,26 @@ Boolean, default `false`. When `false` (default) the note is a passive surface

The trigger is skipped on prompt-shape-gated turns (#674) and is fully fail-soft: any error yields no note and never breaks the hook. Full spec: [phantom_trigger_generation.md](../design/phantom_trigger_generation.md).

## `[phantom_promotion]` (v4.x+)

Opt-in trigger-driven phantom **promotion-opportunity** detection (#1132). The promotion-side mirror of `[phantom_generation]`: on each `UserPromptSubmit` turn aelfrice deterministically checks whether any phantom (`origin='speculative'`) has accumulated enough cross-session corroboration to be worth confirming, and if so appends a small `<aelfrice-phantom-promotion-opportunity>` note naming the candidate(s) and their `aelf validate <id>` / `aelf lock` surface. Origin promotion itself stays exactly where the ratified #229 rule put it — an explicit user act; a corroboration count is a **non-trigger** for the write. This lane only decides *when to prompt* the user; it never promotes autonomously. Default-off; inert until enabled.

The detector answers the #1125 census finding that phantoms are essentially never promoted (0 across seven real stores) not because promotion is broken but because nothing ever surfaces a corroborated phantom for the explicit act #229 requires.

### `enabled`

Boolean, default `false`. Master opt-in. Precedence (first decisive wins): env var `AELFRICE_PHANTOM_PROMOTION=1`/`0` (truthy/falsy normalised) > explicit Python kwarg > TOML `[phantom_promotion] enabled` > default `false`. Mirrors the `[phantom_generation]` resolver shape; a fresh install is unaffected.

### `max_fires_per_session`

Integer ≥ 1, default `3`. Per-session cap on promotion-opportunity notes, tracked in `session_ring` state independently of the `[phantom_generation]` budget. Per-candidate dedup keyed on the phantom's belief id prevents re-surfacing the same candidate within a session. TOML-only (no env override).

### `min_corroborations` / `min_sessions`

Integers ≥ 1, defaults `3` / `2`. A phantom is surfaced only once it has been corroborated at least `min_corroborations` times across at least `min_sessions` distinct sessions (NULL sessions excluded), with no inbound CONTRADICTS edge — the same threshold shape as the retention-promotion rule (`belief_retention_class.md` §4). Raise them to surface fewer, higher-confidence candidates. TOML-only.

The trigger is skipped on prompt-shape-gated turns and is fully fail-soft: any error yields no note and never breaks the hook. Full spec: [phantom_generation_sources.md](../design/phantom_generation_sources.md) §6 (issue #1132).

## `[belief_categories]` (v4.x+)

Opt-in keyword-triggered belief categories (#1126). A *category* groups beliefs (repo-rules, git-workflow, prose-and-docs, …) and binds them to an activation trigger. When the lane is enabled and a category fires — it is always-on, or one of its keyword phrases appears in the prompt — the `UserPromptSubmit` hook **reranks the retrieval output** so that category's member rules lead the `<aelfrice-memory>` block, prepends a one-line `<category-focus>` note naming the fired categories, and surfaces a bounded set of members retrieval missed. This is the conditional, right-rule-at-the-right-moment complement to a static `CLAUDE.md` / `AGENTS.md`.
Expand Down
62 changes: 62 additions & 0 deletions src/aelfrice/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,18 @@ def user_prompt_submit(
)
if phantom_block:
sout.write(phantom_block)
# #1132 Q2 trigger-driven phantom promotion: surface a
# promotion-opportunity note for phantoms that have crossed the
# cross-session corroboration threshold, so the user can validate
# them. Store-state-driven (not prompt-driven); default-off,
# fail-soft.
promotion_block = _maybe_phantom_promotion_block(
session_id=session_id,
cwd=payload_cwd,
stderr=serr,
)
if promotion_block:
sout.write(promotion_block)
except Exception: # non-blocking: surface but do not fail
traceback.print_exc(file=serr)
return 0
Expand Down Expand Up @@ -1222,6 +1234,56 @@ def _maybe_phantom_opportunity_block(
return ""


def _maybe_phantom_promotion_block(
*,
session_id: str | None,
cwd: Path | None = None,
stderr: IO[str] | None = None,
) -> str:
"""Evaluate the #1132 Q2 phantom promotion-opportunity trigger and return
the ``<aelfrice-phantom-promotion-opportunity>`` block, or ``""`` when the
feature is disabled (default) or nothing crosses the threshold.

Fail-soft: any error returns ``""`` and traces to stderr — the promotion
trigger is an additive note and must never break the retrieval contract.
The default-off path is cheap: it resolves the flag and returns before
opening the store.
"""
serr = stderr if stderr is not None else sys.stderr
try:
from aelfrice.phantom_promotion_opportunity import ( # noqa: PLC0415
evaluate_promotion_opportunities,
format_promotion_note,
load_phantom_promotion_config,
)

config = load_phantom_promotion_config(start=cwd)
if not config.enabled:
return ""
p = db_path()
if str(p) == ":memory:":
return ""
from aelfrice.store import MemoryStore # noqa: PLC0415

store = MemoryStore(str(p))
try:
opportunities = evaluate_promotion_opportunities(
store=store,
session_id=session_id,
config=config,
stderr=serr,
)
finally:
store.close()
return format_promotion_note(opportunities)
except Exception as exc: # fail-soft: never break the hook
print(
f"aelfrice: phantom promotion trigger failed (non-fatal): {exc}",
file=serr,
)
return ""


def _read_assistant_text_since(
session_id: str, since_iso: str, *, stderr: IO[str] | None = None,
) -> str:
Expand Down
Loading
Loading