feat: Phase 5F/5G/5H — graduation, conflicts, MCP engineering tools
The population move + the safety net + the universal consumer hookup,
all shipped together. This is where the engineering graph becomes
genuinely useful against the real 262-memory corpus.
5F: Memory → Entity graduation (THE population move)
- src/atocore/engineering/_graduation_prompt.py: stdlib-only shared
prompt module mirroring _llm_prompt.py pattern (container + host
use same system prompt, no drift)
- scripts/graduate_memories.py: host-side batch driver that asks
claude-p "does this memory describe a typed entity?" and creates
entity candidates with source_refs pointing back to the memory
- promote_entity() now scans source_refs for memory:* prefix; if
found, flips source memory to status='graduated' with
graduated_to_entity_id forward pointer + writes memory_audit row
- GET /admin/graduation/stats exposes graduation rate for dashboard
5G: Sync conflict detection on entity promote
- src/atocore/engineering/conflicts.py: detect_conflicts_for_entity()
runs on every active promote. V1 checks 3 slot kinds narrowly to
avoid false positives:
* component.material (multiple USES_MATERIAL edges)
* component.part_of (multiple PART_OF edges)
* requirement.name (duplicate active Requirements in same project)
- Conflicts + members persist via the tables built in 5A
- Fires a "warning" alert via Phase 4 framework
- Deduplicates: same (slot_kind, slot_key) won't get a new row
- resolve_conflict(action="dismiss|supersede_others|no_action"):
supersede_others marks non-winner members as status='superseded'
- GET /admin/conflicts + POST /admin/conflicts/{id}/resolve
5H: MCP + context pack integration
- scripts/atocore_mcp.py: 7 new engineering tools exposed to every
MCP-aware client (Claude Desktop, Claude Code, Cursor, Zed):
* atocore_engineering_map (Q-001/004 system tree)
* atocore_engineering_gaps (Q-006/009/011 killer queries — THE
director's question surfaced as a built-in tool)
* atocore_engineering_requirements_for_component (Q-005)
* atocore_engineering_decisions (Q-008)
* atocore_engineering_changes (Q-013 — reads entity audit log)
* atocore_engineering_impact (Q-016 BFS downstream)
* atocore_engineering_evidence (Q-017 inbound provenance)
- MCP tools total: 14 (7 memory/state/health + 7 engineering)
- context/builder.py _build_engineering_context now appends a compact
gaps summary ("Gaps: N orphan reqs, M risky decisions, K unsupported
claims") so every project-scoped LLM call sees "what we're missing"
Tests: 341 → 356 (15 new):
- 5F: graduation prompt parses positive/negative decisions, rejects
unknown entity types, tolerates markdown fences; promote_entity
marks source memory graduated with forward pointer; entity without
memory refs promotes cleanly
- 5G: component.material + component.part_of + requirement.name
conflicts detected; clean component triggers nothing; dedup works;
supersede_others resolution marks losers; dismiss leaves both
active; end-to-end promote triggers detection
- 5H: graduation user message includes project + type + content
No regressions across the 341 prior tests. The MCP server now answers
"which p05 requirements aren't satisfied?" directly from any Claude
session — no user prompt engineering, no context hacks.
Next to kick off from user: run graduation script on Dalidou to
populate the graph from 262 existing memories:
ssh papa@dalidou 'cd /srv/storage/atocore/app && PYTHONPATH=src \
python3 scripts/graduate_memories.py --project p05-interferometer --limit 30 --dry-run'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -334,14 +334,85 @@ def _set_entity_status(
|
||||
|
||||
|
||||
def promote_entity(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
"""Promote a candidate entity to active."""
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM entities WHERE id = ?", (entity_id,)
|
||||
).fetchone()
|
||||
if row is None or row["status"] != "candidate":
|
||||
"""Promote a candidate entity to active.
|
||||
|
||||
Phase 5F graduation hook: if this entity has source_refs pointing at
|
||||
memories (format "memory:<uuid>"), mark those source memories as
|
||||
``status=graduated`` and set their ``graduated_to_entity_id`` forward
|
||||
pointer. This preserves the memory as an immutable historical record
|
||||
while signalling that it's been absorbed into the typed graph.
|
||||
"""
|
||||
entity = get_entity(entity_id)
|
||||
if entity is None or entity.status != "candidate":
|
||||
return False
|
||||
return _set_entity_status(entity_id, "active", actor=actor, note=note)
|
||||
|
||||
ok = _set_entity_status(entity_id, "active", actor=actor, note=note)
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
# Phase 5F: mark source memories as graduated
|
||||
memory_ids = [
|
||||
ref.split(":", 1)[1]
|
||||
for ref in (entity.source_refs or [])
|
||||
if isinstance(ref, str) and ref.startswith("memory:")
|
||||
]
|
||||
if memory_ids:
|
||||
_graduate_source_memories(memory_ids, entity_id, actor=actor)
|
||||
|
||||
# Phase 5G: sync conflict detection on promote. Fail-open — detection
|
||||
# errors log but never undo the successful promote.
|
||||
try:
|
||||
from atocore.engineering.conflicts import detect_conflicts_for_entity
|
||||
detect_conflicts_for_entity(entity_id)
|
||||
except Exception as e:
|
||||
log.warning("conflict_detection_failed", entity_id=entity_id, error=str(e))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _graduate_source_memories(memory_ids: list[str], entity_id: str, actor: str) -> None:
|
||||
"""Mark source memories as graduated and set forward pointer."""
|
||||
if not memory_ids:
|
||||
return
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
with get_connection() as conn:
|
||||
for mid in memory_ids:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM memories WHERE id = ?", (mid,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
continue
|
||||
old_status = row["status"]
|
||||
if old_status == "graduated":
|
||||
continue # already graduated — maybe by a different entity
|
||||
conn.execute(
|
||||
"UPDATE memories SET status = 'graduated', "
|
||||
"graduated_to_entity_id = ?, updated_at = ? WHERE id = ?",
|
||||
(entity_id, now, mid),
|
||||
)
|
||||
# Write a memory_audit row for the graduation
|
||||
conn.execute(
|
||||
"INSERT INTO memory_audit (id, memory_id, action, actor, "
|
||||
"before_json, after_json, note, entity_kind) "
|
||||
"VALUES (?, ?, 'graduated', ?, ?, ?, ?, 'memory')",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
mid,
|
||||
actor or "api",
|
||||
json.dumps({"status": old_status}),
|
||||
json.dumps({
|
||||
"status": "graduated",
|
||||
"graduated_to_entity_id": entity_id,
|
||||
}),
|
||||
f"graduated to entity {entity_id[:8]}",
|
||||
),
|
||||
)
|
||||
log.info("memory_graduated", memory_id=mid,
|
||||
entity_id=entity_id, old_status=old_status)
|
||||
except Exception as e:
|
||||
log.warning("memory_graduation_failed",
|
||||
memory_id=mid, entity_id=entity_id, error=str(e))
|
||||
|
||||
|
||||
def reject_entity_candidate(entity_id: str, actor: str = "api", note: str = "") -> bool:
|
||||
|
||||
Reference in New Issue
Block a user