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

@@ -22,42 +22,80 @@ The user invoked `/atocore-context` with:
$ARGUMENTS
```
Treat the **entire argument string** as the prompt by default. If the
last whitespace-separated token looks like a registered project id or
alias (`atocore`, `p04`, `p04-gigabit`, `gigabit`, `p05`,
`p05-interferometer`, `interferometer`, `p06`, `p06-polisher`,
`polisher`, or any case-insensitive variant), pull it off and treat
it as an explicit project hint. The remaining tokens become the
prompt. Otherwise leave the project hint empty and the client will
try to auto-detect one from the prompt itself.
You need to figure out two things:
## Step 2 — call the shared client
1. The **prompt text** — what AtoCore will retrieve context for
2. An **optional project hint** — used to scope retrieval to a
specific project's trusted state and corpus
Use the Bash tool. The client respects `ATOCORE_BASE_URL` (default
`http://dalidou:8100`) and is fail-open by default — if AtoCore is
unreachable it returns a `{"status": "unavailable"}` payload and
exits 0, which is what the daily-use loop wants.
The user may have passed a project id or alias as the **last
whitespace-separated token**. Don't maintain a hardcoded list of
known aliases — let the shared client decide. Use this rule:
**If the user passed an explicit project hint**, call `context-build`
directly so AtoCore uses exactly that project:
- Take the last token of `$ARGUMENTS`. Call it `MAYBE_HINT`.
- Run `python scripts/atocore_client.py detect-project "$MAYBE_HINT"`
to ask the registry whether it's a known project id or alias.
This call is cheap (it just hits `/projects` and does a regex
match) and inherits the client's fail-open behavior.
- If the response has a non-null `matched_project`, the last
token was an explicit project hint. `PROMPT_TEXT` is everything
except the last token; `PROJECT_HINT` is the matched canonical
project id.
- Otherwise the last token is just part of the prompt.
`PROMPT_TEXT` is the full `$ARGUMENTS`; `PROJECT_HINT` is empty.
This delegates the alias-knowledge to the registry instead of
embedding a stale list in this markdown file. When you add a new
project to the registry, the slash command picks it up
automatically with no edits here.
## Step 2 — call the shared client for the context pack
The server resolves project hints through the registry before
looking up trusted state, so you can pass either the canonical id
or any alias to `context-build` and the trusted state lookup will
work either way. (Regression test:
`tests/test_context_builder.py::test_alias_hint_resolves_through_registry`.)
**If `PROJECT_HINT` is non-empty**, call `context-build` directly
with that hint:
```bash
python scripts/atocore_client.py context-build \
"<the prompt text>" \
"<the project hint>"
"$PROMPT_TEXT" \
"$PROJECT_HINT"
```
**If no explicit project hint**, call `auto-context` which will run
the client's `detect-project` routing first and only call
`context-build` once it has a match:
**If `PROJECT_HINT` is empty**, do the 2-step fallback dance so the
user always gets a context pack regardless of whether the prompt
implies a project:
```bash
python scripts/atocore_client.py auto-context "<the prompt text>"
# Try project auto-detection first.
RESULT=$(python scripts/atocore_client.py auto-context "$PROMPT_TEXT")
# If auto-context could not detect a project it returns a small
# {"status": "no_project_match", ...} envelope. In that case fall
# back to a corpus-wide context build with no project hint, which
# is the right behaviour for cross-project or generic prompts like
# "what changed in AtoCore backup policy this week?"
if echo "$RESULT" | grep -q '"no_project_match"'; then
RESULT=$(python scripts/atocore_client.py context-build "$PROMPT_TEXT")
fi
echo "$RESULT"
```
In both cases the response is the JSON payload from `/context/build`
(or, for the `auto-context` no-match case, a small
`{"status": "no_project_match"}` envelope).
This is the fix for the P2 finding from codex's review: previously
the slash command sent every no-hint prompt through `auto-context`
and returned `no_project_match` to the user with no context, even
though the underlying client's `context-build` subcommand has
always supported corpus-wide context builds.
In both branches the response is the JSON payload from
`/context/build` (or, in the rare case where even the corpus-wide
build fails, a `{"status": "unavailable"}` envelope from the
client's fail-open layer).
## Step 3 — present the context pack to the user
@@ -78,21 +116,15 @@ Render in this order:
3. The `chunks` array as a small bullet list with `source_file`,
`heading_path`, and `score` per chunk
Three special cases:
Two special cases:
- **`{"status": "no_project_match"}`** (from `auto-context`)
→ Tell the user: "AtoCore could not auto-detect a project from the
prompt. Re-run with an explicit project id:
`/atocore-context <prompt> <project-id>` (or call without a hint
to use the corpus-wide context build)."
- **`{"status": "unavailable"}`** (fail-open from the client)
→ Tell the user: "AtoCore is unreachable at `$ATOCORE_BASE_URL`.
Check `python scripts/atocore_client.py health` for diagnostics."
- **Empty `chunks_used: 0` with no project state and no memories**
→ Tell the user: "AtoCore returned no context for this prompt —
either the corpus does not have relevant information for the
detected project or the project hint is wrong. Try a different
hint or a longer prompt."
either the corpus does not have relevant information or the
project hint is wrong. Try a different hint or a longer prompt."
## Step 4 — what about capturing the interaction
@@ -115,8 +147,11 @@ queued.
client is the contract between AtoCore and every LLM frontend; if
you find a missing capability, the right fix is to extend the
client, not to work around it.
- DO NOT silently change `ATOCORE_BASE_URL`. If the env var points at
the wrong instance, surface the error so the user can fix it.
- DO NOT maintain a hardcoded list of project aliases in this
file. Use `detect-project` to ask the registry — that's the
whole point of having a registry.
- DO NOT silently change `ATOCORE_BASE_URL`. If the env var points
at the wrong instance, surface the error so the user can fix it.
- DO NOT hide the formatted context pack from the user. Showing
what AtoCore would feed an LLM is the whole point.
- The output goes into the user's working context as background;

View File

@@ -14,6 +14,7 @@ import atocore.config as _config
from atocore.context.project_state import format_project_state, get_state
from atocore.memory.service import get_memories_for_context
from atocore.observability.logger import get_logger
from atocore.projects.registry import get_registered_project
from atocore.retrieval.retriever import ChunkResult, retrieve
log = get_logger("context_builder")
@@ -84,8 +85,21 @@ def build_context(
max(0, int(budget * PROJECT_STATE_BUDGET_RATIO)),
)
# Resolve the project hint through the registry so callers can pass
# an alias (`p05`, `gigabit`) and still find trusted state stored
# under the canonical project id (`p05-interferometer`,
# `p04-gigabit`). The retriever already does this for the
# project-match boost — the project_state lookup needs the same
# courtesy. If the registry has no entry for the hint, fall back to
# the raw hint so a hand-curated project_state entry that predates
# the registry still works.
canonical_project = project_hint
if project_hint:
state_entries = get_state(project_hint)
registered = get_registered_project(project_hint)
if registered is not None:
canonical_project = registered.project_id
state_entries = get_state(canonical_project)
if state_entries:
project_state_text = format_project_state(state_entries)
project_state_text, project_state_chars = _truncate_text_block(

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