fix(P1+P2): alias-aware project state lookup + slash command corpus fallback

Two regression fixes from codex's review of the slash command
refactor commit (78d4e97). Both findings are real and now have
covered tests.

P1 — server-side alias resolution for project_state lookup
----------------------------------------------------------
The bug:
- /context/build forwarded the caller's project hint verbatim to
  get_state(project_hint), which does an exact-name lookup against
  the projects table (case-insensitive but no alias resolution)
- the project registry's alias matching was only used by the
  client's auto-context path and the retriever's project-match
  boost, never by the server's project_state lookup
- consequence: /atocore-context "... p05" would silently miss
  trusted project state stored under the canonical id
  "p05-interferometer", weakening project-hinted retrieval to
  the point that an explicit alias hint was *worse* than no hint

The fix in src/atocore/context/builder.py:
- import get_registered_project from the projects registry
- before calling get_state(project_hint), resolve the hint
  through get_registered_project; if a registry record exists,
  use the canonical project_id for the state lookup
- if no registry record exists, fall back to the raw hint so a
  hand-curated project_state entry that predates the registry
  still works (backwards compat with pre-registry deployments)

The retriever already does its own alias expansion via
get_registered_project for the project-match boost, so the
retriever side was never broken — only the project_state lookup
in the builder. The fix is scoped to that one call site.

Tests added in tests/test_context_builder.py:
- test_alias_hint_resolves_through_registry: stands up a fresh
  registry, sets state under "p05-interferometer", then verifies
  build_context with project_hint="p05" finds the state, AND
  with project_hint="interferometer" (the second alias) finds it
  too, AND with the canonical id finds it. Covers all three
  resolution paths.
- test_unknown_hint_falls_back_to_raw_lookup: empty registry,
  set state under an unregistered project name, verify the
  build_context call with that name as the hint still finds the
  state. Locks in the backwards-compat behavior.

P2 — slash command no-hint fallback to corpus-wide context build
----------------------------------------------------------------
The bug:
- the slash command's no-hint path called auto-context, which
  returns {"status": "no_project_match"} when project detection
  fails and does NOT fall back to a plain context-build
- the slash command's own help text told the user "call without
  a hint to use the corpus-wide context build" — which was a lie
  because the wrapper no longer did that
- consequence: generic prompts like "what changed in AtoCore
  backup policy?" or any cross-project question got a useless
  no_project_match envelope instead of a context pack

The fix in .claude/commands/atocore-context.md:
- the no-hint path now does the 2-step fallback dance:
    1. try `auto-context "<prompt>"` for project detection
    2. if the response contains "no_project_match", fall back to
       `context-build "<prompt>"` (no project arg)
- both branches return a real context pack, fail-open envelope
  is preserved for genuine network errors
- the underlying client surface is unchanged (no new flags, no
  new subcommands) — the fallback is per-frontend logic in the
  slash command, leaving auto-context's existing semantics
  intact for OpenClaw and any other caller that depends on the
  no_project_match envelope as a "do nothing" signal

While I was here, also tightened the slash command's argument
parsing to delegate alias-knowledge to the registry instead of
embedding a hardcoded list:
- old version had a literal list of "atocore", "p04", "p05",
  "p06" and their aliases that needed manual maintenance every
  time a project was added
- new version takes the last token of $ARGUMENTS and asks the
  client's `detect-project` subcommand whether it's a known
  alias; if matched, it's the explicit hint, if not it's part
  of the prompt
- this delegates registry knowledge to the registry, where it
  belongs

Unrelated improvement noted but NOT fixed in this commit:
- _rank_chunks in builder.py also has a naive substring boost
  that uses the original hint without alias expansion. The
  retriever already does the right thing, so this secondary
  boost is redundant. Tracked as a future cleanup but not in
  scope for the P1/P2 fix; codex's findings are about
  project_state lookup, not about the secondary chunk boost.

Full suite: 162 passing (was 160), 1 warning. The +2 is the two
new P1 regression tests.
This commit is contained in:
2026-04-07 07:47:03 -04:00
parent 78d4e979e5
commit f2372eff9e
3 changed files with 174 additions and 36 deletions

View File

@@ -1,5 +1,8 @@
"""Tests for the context builder."""
import json
import atocore.config as config
from atocore.context.builder import build_context, get_last_context_pack
from atocore.context.project_state import init_project_state_schema, set_state
from atocore.ingestion.pipeline import ingest_file
@@ -162,3 +165,89 @@ def test_no_project_state_without_hint(tmp_data_dir, sample_markdown):
pack = build_context("What is AtoCore?")
assert pack.project_state_chars == 0
assert "--- Trusted Project State ---" not in pack.formatted_context
def test_alias_hint_resolves_through_registry(tmp_data_dir, sample_markdown, monkeypatch):
"""An alias hint like 'p05' should find project state stored under 'p05-interferometer'.
This is the regression test for the P1 finding from codex's review:
/context/build was previously doing an exact-name lookup that
silently dropped trusted project state when the caller passed an
alias instead of the canonical project id.
"""
init_db()
init_project_state_schema()
ingest_file(sample_markdown)
# Stand up a minimal project registry that knows the aliases.
# The registry lives in a JSON file pointed to by
# ATOCORE_PROJECT_REGISTRY_PATH; the dataclass-driven loader picks
# it up on every call (no in-process cache to invalidate).
registry_path = tmp_data_dir / "project-registry.json"
registry_path.write_text(
json.dumps(
{
"projects": [
{
"id": "p05-interferometer",
"aliases": ["p05", "interferometer"],
"description": "P05 alias-resolution regression test",
"ingest_roots": [
{"source": "vault", "subpath": "incoming/projects/p05"}
],
}
]
}
),
encoding="utf-8",
)
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
config.settings = config.Settings()
# Trusted state is stored under the canonical id (the way the
# /project/state endpoint always writes it).
set_state(
"p05-interferometer",
"status",
"next_focus",
"Wave 2 trusted-operational ingestion",
)
# The bug: pack with alias hint used to silently miss the state.
pack_with_alias = build_context("status?", project_hint="p05", budget=2000)
assert "Wave 2 trusted-operational ingestion" in pack_with_alias.formatted_context
assert pack_with_alias.project_state_chars > 0
# The canonical id should still work the same way.
pack_with_canonical = build_context(
"status?", project_hint="p05-interferometer", budget=2000
)
assert "Wave 2 trusted-operational ingestion" in pack_with_canonical.formatted_context
# A second alias should also resolve.
pack_with_other_alias = build_context(
"status?", project_hint="interferometer", budget=2000
)
assert "Wave 2 trusted-operational ingestion" in pack_with_other_alias.formatted_context
def test_unknown_hint_falls_back_to_raw_lookup(tmp_data_dir, sample_markdown, monkeypatch):
"""A hint that isn't in the registry should still try the raw name.
This preserves backwards compatibility with hand-curated
project_state entries that predate the project registry.
"""
init_db()
init_project_state_schema()
ingest_file(sample_markdown)
# Empty registry — the hint won't resolve through it.
registry_path = tmp_data_dir / "project-registry.json"
registry_path.write_text('{"projects": []}', encoding="utf-8")
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
config.settings = config.Settings()
set_state("orphan-project", "status", "phase", "Solo run")
pack = build_context("status?", project_hint="orphan-project", budget=2000)
assert "Solo run" in pack.formatted_context