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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Example:
{"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}}
```

**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics; when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint).
**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success; empty results with non-empty `edge_types` may emit kind- and direction-aware structural hints driven by `EDGE_SCHEMA` (see [`propose/completed/HINTS-V3-PROPOSE.md`](./propose/completed/HINTS-V3-PROPOSE.md)); when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear on non-empty results. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint).

---

Expand Down
46 changes: 16 additions & 30 deletions docs/PROPOSES-ORDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,69 +11,55 @@ When two or more proposes touch overlapping subsystems, the order they lock and

## Current in-flight set (as of 2026-05-16)

1. **HINTS-V3** — `propose/HINTS-V3-PROPOSE.md` (`Status: locked — implementing via SCHEMA-V2 PR-D`; propose [#154](https://github.com/HumanBean17/java-codebase-rag/pull/154), plan [#155](https://github.com/HumanBean17/java-codebase-rag/pull/155))
- Implementation = SCHEMA-V2 PR-D (same PR).
No proposes are in flight.

**SCHEMA-V2** (PR-A/B/C) is **completed** — artefacts in `propose/completed/SCHEMA-V2-PROPOSE.md`, `plans/completed/PLAN-SCHEMA-V2.md`, `plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md`. PR-D remains under HINTS-V3.
**SCHEMA-V2** and **HINTS-V3** are **completed** — artefacts under `propose/completed/` and `plans/completed/` (SCHEMA PR-A/B/C; HINTS-V3 PR-D [#160](https://github.com/HumanBean17/java-codebase-rag/pull/160)).

No other proposes are in flight.

## Lock and merge order
## Lock and merge order (archived)

### Phase 1 — propose artefacts

```
SCHEMA-V2-PROPOSE.md [merged #151 — completed; propose/completed/]
HINTS-V3-PROPOSE.md [merged #154 — implementing in PR-D]
HINTS-V3-PROPOSE.md [merged #154 — completed; propose/completed/; code #160]
```

**Decision 30 (SCHEMA-V2)**: `HINTS-V3-PROPOSE.md` must exist as a **merged draft propose** before SCHEMA-V2 **PR-A** implementation starts. That unblocks the four-code-PR sequence; it does **not** require HINTS-V3 to be `Status: locked` before PR-A.
**Decision 30 (SCHEMA-V2)**: `HINTS-V3-PROPOSE.md` must exist as a **merged draft propose** before SCHEMA-V2 **PR-A** implementation starts.

**HINTS-V3 lock**: `Status: locked` before SCHEMA-V2 **PR-D** code merges (satisfied while Phase 3 is in flight; see propose headers).
**HINTS-V3 lock**: `Status: locked` before SCHEMA-V2 **PR-D** code merges.

### Phase 2 — plan + cursor-prompt artefacts

```
plans/completed/PLAN-SCHEMA-V2.md [landed #155; PR-A/B/C done]
plans/completed/PLAN-SCHEMA-V2.md
plans/completed/CURSOR-PROMPTS-SCHEMA-V2.md
plans/PLAN-HINTS-V3.md
plans/CURSOR-PROMPTS-HINTS-V3.md
plans/completed/PLAN-HINTS-V3.md
plans/completed/CURSOR-PROMPTS-HINTS-V3.md
```

SCHEMA-V2 Decision 29: plan + prompts merge gates for **PR-A** — satisfied ([#155](https://github.com/HumanBean17/java-codebase-rag/pull/155) on `master`; artefacts now under `plans/completed/`).

By analogy: `PLAN-HINTS-V3.md` + `CURSOR-PROMPTS-HINTS-V3.md` are merge gates for **PR-D** (same PR).

Plans and prompts may be drafted in parallel with each other; each pair must land before its code PR. **Code PRs (Phase 3) are not started** until Phase 2 is on `master`.

### Phase 3 — code PRs (merge order) — **implementing**
### Phase 3 — code PRs (merge order) — **completed**

```
PR-A feat(schema): EDGE_SCHEMA + docs/EDGE-NAVIGATION.md + ontology v14
(requires HINTS-V3 propose merged as draft per Decision 30)
PR-B feat(schema): HTTP_CALLS Client → Route (+ downstream API)
PR-C feat(schema): Producer node + ASYNC_CALLS flip (+ GraphMeta / MCP parity)
(requires HINTS-V3 propose Status: locked)
PR-D feat(hints): kind/direction-aware empty-result hints (EDGE_SCHEMA-driven)
PR-D feat(hints): kind/direction-aware empty-result hints (EDGE_SCHEMA-driven) [#160]
```

PR-A needs `EDGE_SCHEMA` infrastructure. PR-B and PR-C are sequential for review surface. PR-D consumes post-flip `src`/`dst` and must not merge until HINTS-V3 is **locked**.

No PR in this set is parallelizable.

## Re-index moments

`ONTOLOGY_VERSION` 13 → 14 lands in PR-A. **One** re-index across the sequence. README + `docs/AGENT-GUIDE.md` updated in PR-A.
`ONTOLOGY_VERSION` 13 → 14 landed in PR-A. **One** re-index across the SCHEMA-V2 sequence.

## What this document does NOT cover

- Per-PR deliverables — `plans/PLAN-*.md`
- Per-PR deliverables — `plans/PLAN-*.md` (see `plans/completed/` for landed work)
- Cursor handoffs — `plans/CURSOR-PROMPTS-*.md`
- Out-of-scope proposes (TIER2-INCREMENTAL-REBUILD, RANKING-MICROSERVICE, etc.)
- Intra-PR review threads

## Maintenance

Update this file when a propose enters draft, locks, or its code PRs land. After PR-D merges, move HINTS-V3 artefacts to `completed/` and collapse to "no proposes in flight" until the next effort starts.
Update this file when a propose enters draft, locks, or its code PRs land. After the next effort starts, add it to "Current in-flight set" and extend the archived sequence as needed.
185 changes: 176 additions & 9 deletions mcp_hints.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
"""Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM).

Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A.
v2 resolve + neighbors fuzzy-strategy catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A.
v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A.
v3 empty-neighbors structural catalog: ``propose/completed/HINTS-V3-PROPOSE.md`` §3.1–3.3.
Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles.
"""

from __future__ import annotations

from typing import Any, Literal

from java_ontology import FUZZY_STRATEGY_SET
from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET

# Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...).
MCP_HINTS_FIELD_DESCRIPTION = (
"Road-sign hints pointing to likely next calls. Each hint is a short string "
"referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. "
"Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed "
"rollups) as neighbors() arguments."
"rollups) as neighbors() arguments. For neighbors with multiple origin ids, "
"empty-result structural hints describe the first origin only."
)

# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
Expand All @@ -43,8 +45,25 @@
TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate"

TPL_NEIGHBORS_EMPTY_KIND_CHECK = (
"0 results — check if the requested edge_types apply to this kind"
TPL_NEIGHBORS_WRONG_SUBJECT_KIND = (
"0 results — '{edge}' connects {src_kind} → {dst_kind}; "
"this is a {subject_kind}. Try: {canonical_traversal}"
)

TPL_NEIGHBORS_WRONG_DIRECTION = (
"0 results — '{edge}' is {src_kind} → {dst_kind}; "
"you requested direction='{requested_dir}'. Try direction='{correct_dir}'."
)

TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = (
"0 results — '{edge}' lives on methods, not on {subject_kind}. "
"Try: {canonical_traversal}"
)

TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = (
"edges on '{edge}' are emitted by the brownfield resolver — "
"absence here may mean unresolved (no matching annotation/target), "
"not absent from the codebase"
)

TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)"
Expand Down Expand Up @@ -80,6 +99,12 @@
_TYPE_SYMBOL_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"})
_METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"})

_COMPOSED_DOT_KEY_PREFIXES = ("DECLARES.", "OVERRIDDEN_BY.")
# Row 4 (brownfield absence): only when the subject is a resolver endpoint node, not a
# structurally valid Symbol query that happens to be empty (DECLARES_CLIENT, EXPOSES, …).
_BROWNFIELD_ABSENCE_SUBJECT_LABELS = frozenset({"Client", "Producer", "Route"})
_REQUIRED_TRAVERSAL_ROLE_KEYS = frozenset({"type_subject", "member_subject", "alien_subject"})

_IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = {
"symbol": ("fqn_prefix",),
"route": ("path_prefix",),
Expand All @@ -105,6 +130,134 @@ def _symbol_declaration_kind(record: dict[str, Any]) -> str | None:
return None


def _subject_node_label(subject_record: dict[str, Any]) -> str:
if "producer_kind" in subject_record:
return "Producer"
if "client_kind" in subject_record:
return "Client"
if "framework" in subject_record:
return "Route"
return "Symbol"


def _traversal_role_for_wrong_kind(subject_label: str, subject_record: dict[str, Any]) -> str:
if subject_label == "Symbol":
sk = str(subject_record.get("kind") or "")
if sk in _METHOD_SYMBOL_KINDS:
return "member_subject"
if sk in _TYPE_SYMBOL_KINDS:
return "alien_subject"
return "alien_subject"


def typical_traversal_for(
edge: str,
role_key: str,
*,
subject_id: str,
direction: str,
) -> str:
template = EDGE_SCHEMA[edge].typical_traversals[role_key]
return template.format(id=subject_id, direction=direction, edge=edge)


def neighbors_empty_hints(
*,
subject_record: dict[str, Any],
requested_edge_types: list[str],
requested_direction: Literal["in", "out"],
) -> list[tuple[int, str]]:
"""Structural empty-neighbors hints from ``EDGE_SCHEMA`` (at most one row 1–3 per edge)."""
pairs: list[tuple[int, str]] = []
subject_label = _subject_node_label(subject_record)
subject_id = str(subject_record.get("id") or "")

for edge in requested_edge_types:
spec = EDGE_SCHEMA.get(edge)
if spec is None:
continue

if subject_label != spec.src and subject_label != spec.dst:
role = _traversal_role_for_wrong_kind(subject_label, subject_record)
trav = typical_traversal_for(
edge, role, subject_id=subject_id, direction=requested_direction,
)
pairs.append(
(
PRIORITY_META,
TPL_NEIGHBORS_WRONG_SUBJECT_KIND.format(
edge=edge,
src_kind=spec.src,
dst_kind=spec.dst,
subject_kind=subject_label,
canonical_traversal=trav,
),
)
)
continue

wrong_direction = spec.src != spec.dst and (
(requested_direction == "out" and subject_label == spec.dst)
or (requested_direction == "in" and subject_label == spec.src)
)
if wrong_direction:
correct_dir = "in" if requested_direction == "out" else "out"
pairs.append(
(
PRIORITY_META,
TPL_NEIGHBORS_WRONG_DIRECTION.format(
edge=edge,
src_kind=spec.src,
dst_kind=spec.dst,
requested_dir=requested_direction,
correct_dir=correct_dir,
),
)
)
continue

if (
subject_label == "Symbol"
and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
and spec.member_only
):
trav = typical_traversal_for(
edge, "type_subject", subject_id=subject_id, direction=requested_direction,
)
pairs.append(
(
PRIORITY_META,
TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.format(
edge=edge,
subject_kind=subject_label,
canonical_traversal=trav,
),
)
)

if subject_label in _BROWNFIELD_ABSENCE_SUBJECT_LABELS:
for edge in requested_edge_types:
spec = EDGE_SCHEMA.get(edge)
if spec is not None and spec.brownfield_resolver_sourced:
pairs.append(
(
PRIORITY_META,
TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge),
)
)
break

return pairs


def _hint_contains_composed_dotkey(hint: str) -> bool:
return any(prefix in hint for prefix in _COMPOSED_DOT_KEY_PREFIXES)


def _filter_neighbors_dotkey_hints(pairs: list[tuple[int, str]]) -> list[tuple[int, str]]:
return [(pri, text) for pri, text in pairs if not _hint_contains_composed_dotkey(text)]


def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
for e in edges:
attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
Expand Down Expand Up @@ -227,12 +380,26 @@ def generate_hints(
req_types = payload.get("requested_edge_types")
if not isinstance(req_types, list):
req_types = []
n_types = len([x for x in req_types if str(x).strip()])
if not results and n_types > 0:
pairs.append((PRIORITY_META, TPL_NEIGHBORS_EMPTY_KIND_CHECK))
edge_labels = [str(x).strip() for x in req_types if str(x).strip()]
offset = int(payload.get("offset") or 0)
if not results and edge_labels and offset == 0:
subject_record = payload.get("subject_record")
requested_direction = payload.get("requested_direction")
if (
isinstance(subject_record, dict)
and subject_record
and requested_direction in ("in", "out")
):
pairs.extend(
neighbors_empty_hints(
subject_record=subject_record,
requested_edge_types=edge_labels,
requested_direction=requested_direction,
)
)
elif _any_fuzzy_strategy(results):
pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
return finalize_hint_list(pairs)
return finalize_hint_list(_filter_neighbors_dotkey_hints(pairs))

if output_kind == "describe":
rec = payload.get("record")
Expand Down
9 changes: 9 additions & 0 deletions mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1391,10 +1391,19 @@ def neighbors_v2(
)
)
sliced = results[offset : offset + limit]
first_origin = origins[0]
origin_kind = _resolve_node_kind(g, first_origin)
subject_record = _load_node_record(g, first_origin, origin_kind)
# Empty-result hints use the sliced page only; offset>0 or strict filters can
# yield [] while hops exist — skip structural hints in that case.
neigh_payload = {
"success": True,
"results": [e.model_dump() for e in sliced],
"requested_edge_types": list(labels),
"requested_direction": direction,
"offset": offset,
"origin_id": first_origin,
"subject_record": subject_record,
}
return NeighborsOutput(
success=True,
Expand Down
Loading
Loading