diff --git a/DEV-LEDGER.md b/DEV-LEDGER.md index 64743d2..5c1bfe9 100644 --- a/DEV-LEDGER.md +++ b/DEV-LEDGER.md @@ -9,7 +9,7 @@ - **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z) - **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed) - **main_tip**: `999788b` -- **test_count**: 521 (prior 509 + 12 new PATCH-entity tests) +- **test_count**: 533 (prior 521 + 12 new wikilink/redlink tests) - **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression) - **vectors**: 33,253 - **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity) @@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha ## Session Log +- **2026-04-22 Claude (pm)** Issue B (wiki redlinks) landed — last remaining P2 from Antoine's sprint plan. `_wikilink_transform(text, current_project)` in `src/atocore/engineering/wiki.py` replaces `[[Name]]` / `[[Name|Display]]` tokens (pre-markdown) with HTML anchors. Resolution order: same-project exact-name match → live `wikilink`; other-project match → live link with `(in project X)` scope indicator (`wikilink-cross`); no match → `redlink` pointing at `/wiki/new?name=&project=`. New route `GET /wiki/new` renders a pre-filled "create this entity" form that POSTs to `/v1/entities` via a minimal inline fetch() and redirects to the new entity's wiki page on success. Transform applied in `render_project` (over the mirror markdown) and `render_entity` (over the description body). CSS: dashed-underline accent for live wikilinks, red italic + dashed for redlinks. 12 new tests including the regression from the spec (entity A references `[[EntityB]]` → initial render has `class="redlink"`; after EntityB is created, re-render no longer has redlink and includes `/wiki/entities/{b.id}`). Tests 521 → 533. All 6 acceptance criteria from the sprint plan ("daily-usable") now green: retract/supersede, edit without cloning, cross-project has a home, visual evidence, wiki readable, AKC can capture reliably. + - **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order. - **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes. diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 7ba1249..ecffa93 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -129,6 +129,13 @@ def wiki_capture() -> HTMLResponse: return HTMLResponse(content=render_capture()) +@router.get("/wiki/new", response_class=HTMLResponse) +def wiki_new_entity(name: str = "", project: str = "") -> HTMLResponse: + """Issue B: "create this entity" form pre-filled from a redlink.""" + from atocore.engineering.wiki import render_new_entity_form + return HTMLResponse(content=render_new_entity_form(name=name, project=project)) + + @router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse) def wiki_memory(memory_id: str) -> HTMLResponse: """Phase 7E: memory detail with audit trail + neighbors.""" diff --git a/src/atocore/engineering/wiki.py b/src/atocore/engineering/wiki.py index 3d7fee8..3ad8d36 100644 --- a/src/atocore/engineering/wiki.py +++ b/src/atocore/engineering/wiki.py @@ -262,6 +262,10 @@ def render_project(project: str) -> str: from atocore.engineering.mirror import generate_project_overview markdown_content = generate_project_overview(project) + # Resolve [[Wikilinks]] before markdown so redlinks / cross-project + # indicators appear in the rendered HTML. (Issue B) + markdown_content = _wikilink_transform(markdown_content, current_project=project) + # Convert entity names to links entities = get_entities(project=project, limit=200) html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"]) @@ -277,6 +281,145 @@ def render_project(project: str) -> str: ) +import re as _re + +_WIKILINK_PATTERN = _re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]+?))?\]\]") + + +def _resolve_wikilink(target: str, current_project: str | None) -> tuple[str, str, str]: + """Resolve a ``[[Name]]`` target to ``(href, css_class, extra_suffix)``. + + Resolution order (Issue B): + + 1. Same-project exact name match → live link (class ``wikilink``). + 2. Other-project exact name match → live link with ``(in project X)`` + suffix (class ``wikilink wikilink-cross``). + 3. No match → redlink pointing at ``/wiki/new?name=...`` so clicking + opens a pre-filled "create this entity" form (class ``redlink``). + """ + needle = target.strip() + if not needle: + return ("/wiki/new", "redlink", "") + + same_project = None + cross_project = None + try: + candidates = get_entities(name_contains=needle, limit=200) + except Exception: + candidates = [] + + lowered = needle.lower() + for ent in candidates: + if ent.name.lower() != lowered: + continue + if current_project and ent.project == current_project: + same_project = ent + break + if cross_project is None: + cross_project = ent + + if same_project is not None: + return (f"/wiki/entities/{same_project.id}", "wikilink", "") + if cross_project is not None: + suffix = ( + f' (in {cross_project.project})' + if cross_project.project + else ' (global)' + ) + return (f"/wiki/entities/{cross_project.id}", "wikilink wikilink-cross", suffix) + + from urllib.parse import quote + href = f"/wiki/new?name={quote(needle)}" + if current_project: + href += f"&project={quote(current_project)}" + return (href, "redlink", "") + + +def _wikilink_transform(text: str, current_project: str | None) -> str: + """Replace ``[[Name]]`` / ``[[Name|Display]]`` tokens with HTML anchors. + + Runs before markdown rendering. Emits raw HTML which python-markdown + preserves unchanged. + """ + if not text or "[[" not in text: + return text + + def _sub(match: _re.Match) -> str: + target = match.group(1) + display = (match.group(2) or target).strip() + href, cls, suffix = _resolve_wikilink(target, current_project) + title = "create this entity" if cls == "redlink" else target.strip() + return ( + f'' + f'{_escape_html(display)}{suffix}' + ) + + return _WIKILINK_PATTERN.sub(_sub, text) + + +def render_new_entity_form(name: str = "", project: str = "") -> str: + """Issue B — "create this entity" form targeted by redlinks.""" + from atocore.engineering.service import ENTITY_TYPES + + safe_name = _escape_attr(name or "") + safe_project = _escape_attr(project or "") + opts = "".join( + f'' for t in ENTITY_TYPES + ) + lines = [ + '

Create entity

', + ('

This entity was referenced via a wikilink but does not exist yet. ' + 'Fill in the details to create it — the wiki link will resolve on reload.

'), + '
', + f'', + f'', + f'', + '', + '', + '
', + '
', + """""", + ] + return render_html( + f"Create {name}" if name else "Create entity", + "\n".join(lines), + breadcrumbs=[("Wiki", "/wiki"), ("Create entity", "")], + ) + + def _render_visual_evidence(entity_id: str, ctx: dict) -> str: """Render EVIDENCED_BY → artifact links as an inline thumbnail strip.""" from atocore.assets import get_asset @@ -397,7 +540,8 @@ def render_entity(entity_id: str) -> str | None: if ent.project: lines.append(f'

Project: {ent.project}

') if ent.description: - lines.append(f'

{ent.description}

') + desc_html = _wikilink_transform(ent.description, current_project=ent.project) + lines.append(f'

{desc_html}

') if ent.properties: lines.append('

Properties