feat(WP-0011): warden route lookup CLI over the pointer catalog

Add a read-only `warden route` command group (list/show/find) that reads
registry/routing/catalog.yaml and tells a worker which subsystem owns a need
and which wiki/canon doc to follow. ops-warden still executes exactly one lane
(SSH); routed entries return a pointer and never call any subsystem.

- src/warden/routing/: models.py + catalog.py loader; enforces the
  no-double-source rule (non-SSH entries with steps/cert_command fail validation),
  dup-id and schema checks.
- route list (active-only unless --all, --tag), route show (SSH appends steps +
  cert pattern; routed ends with "next action on <owner> — see <wiki_ref>"),
  route find (keyword ranking, --json).
- tests/test_routing.py: load/validation, find ranking, CLI JSON shapes, plus a
  drift guard (every wiki_ref anchor resolves; every entry has a reviewed date).
- Docs: wiki/AccessRouting.md CLI section, README quick reference, SCOPE A3 -> A4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:03:24 +02:00
parent 407cd2e1f4
commit ac2efa1262
10 changed files with 690 additions and 32 deletions

View File

@@ -0,0 +1,49 @@
"""Data model for routing catalog entries.
A `RouteEntry` is a pointer: it names the owner and the authoritative doc for a
credential need. Only the SSH lane (`warden_executes: true`) may carry an authored
`steps` block and a `cert_command` pattern — every other entry is identifiers and
pointers only (the no-double-source rule, enforced in `catalog.py`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class RouteEntry:
id: str
title: str
need_keywords: List[str]
owner_repo: str
subsystem: str
warden_executes: bool
wiki_ref: str
canon_ref: str
reviewed: str
status: str # "active" | "draft"
# SSH lane only — None/empty for routed (non-executed) needs.
steps: List[str] = field(default_factory=list)
cert_command: Optional[str] = None
@property
def is_active(self) -> bool:
return self.status == "active"
def match_score(self, tokens: List[str]) -> int:
"""Keyword-overlap score against need_keywords, title, and id.
Pure ranking helper — no I/O, no external calls.
"""
haystack = set(k.lower() for k in self.need_keywords)
haystack.update(self.id.lower().replace("-", " ").split())
haystack.update(self.title.lower().replace("-", " ").split())
score = 0
for tok in tokens:
t = tok.lower()
if t in haystack:
score += 2
elif any(t in h or h in t for h in haystack):
score += 1
return score