From e72966a658f650a31b2947ca3c5a199c8a3c2137 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 7 Jul 2026 00:57:18 +0200 Subject: [PATCH] REUSE-WP-0018 T01/T02/T04/T06: plan-check deterministic matching + State Hub bridge T01: specs/PlanCheck.md design doc, plan-check-result.schema.json and reuse-event.schema.json (the latter shared with WP-0019's reuse telemetry). T02: reuse_surface/plan_check.py + 'reuse-surface plan-check' CLI command. Deterministic token-Jaccard matching against registry/indexes/federated.yaml (reuses overlaps.py's TOKEN_RE rather than a second scoring method), with reuse/extend/new verdicts, markdown and --format json output, staleness warning, and --record-outcome JSONL telemetry. T04: reuse_surface/statehub_bridge.py bridges plan-check 'new' verdicts to State Hub capability requests (--file-request) and surfaces open requests with no matching capability (report gaps --check-capability-requests, opt-in to stay offline-safe). Verified against the live local State Hub API; status field (not catalog_entry_id presence) is the correct open/closed signal, and the list endpoint needs a longer timeout than the health check (~7s observed with 5 rows). T06: docs (tools/README.md, RegistryFederation.md, SCOPE.md, IntentScopeGapAnalysis.md priority 29) and an informational CI smoke step. T03 (LLM rerank) not started -- llm-connect isn't running on this workstation. T05 (ecosystem rollout) remains blocked: WP-0017 has drafted entries for all 61 repos but they're still local-only pending its own T05 push/publish pass, so the federated index isn't yet worth rolling out plan-check as ecosystem convention. 16 new tests, all mocked -- no network calls in the default test run. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/ci.yml | 3 + SCOPE.md | 5 + docs/IntentScopeGapAnalysis.md | 7 +- docs/RegistryFederation.md | 17 + reuse_surface/cli.py | 123 +++++++ reuse_surface/plan_check.py | 299 ++++++++++++++++++ reuse_surface/statehub_bridge.py | 103 ++++++ schemas/plan-check-result.schema.json | 54 ++++ schemas/reuse-event.schema.json | 29 ++ specs/PlanCheck.md | 169 ++++++++++ tests/test_plan_check.py | 198 ++++++++++++ tests/test_reports.py | 23 ++ tests/test_statehub_bridge.py | 66 ++++ tools/README.md | 28 ++ ...USE-WP-0018-plan-check-consumption-loop.md | 84 +++-- 15 files changed, 1179 insertions(+), 29 deletions(-) create mode 100644 reuse_surface/plan_check.py create mode 100644 reuse_surface/statehub_bridge.py create mode 100644 schemas/plan-check-result.schema.json create mode 100644 schemas/reuse-event.schema.json create mode 100644 specs/PlanCheck.md create mode 100644 tests/test_plan_check.py create mode 100644 tests/test_statehub_bridge.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 96ad460..706f6f0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -49,5 +49,8 @@ jobs: - name: Registry gap report (informational) run: reuse-surface report gaps || true + - name: Plan-check smoke test (informational) + run: reuse-surface plan-check --intent "smoke test" --format json || true + - name: Run tests run: pytest -q \ No newline at end of file diff --git a/SCOPE.md b/SCOPE.md index e136d5b..3c6e9ad 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -75,6 +75,11 @@ The MVP registry foundation, CLI tooling (REUSE-WP-0003), federation stack - **Explore relations interactively** at `docs/graph/index.html` - **Avoid duplicates** by querying the index and checking overlaps before adding entries +- **Check before building** with `reuse-surface plan-check` (REUSE-WP-0018) — + match a draft workplan or free-text intent against the federated index and + get a reuse/extend/new verdict; `--file-request` bridges a `new` verdict to + a State Hub capability request; `report gaps --check-capability-requests` + surfaces open requests with no matching capability Registry **tooling** availability is **A4** (CLI plus hosted hub HTTP API). Registry **authoring** remains Markdown-first; consumption combines entries, the diff --git a/docs/IntentScopeGapAnalysis.md b/docs/IntentScopeGapAnalysis.md index 54a0b89..9c6baf9 100644 --- a/docs/IntentScopeGapAnalysis.md +++ b/docs/IntentScopeGapAnalysis.md @@ -202,9 +202,11 @@ See §4 and archived workplans `workplans/archived/`. | 26 | Federated ID deduplication | Per-owner removal from reuse-surface index | **Closed** (WP-0015-T02) | | 27 | Planning analytics + standardization | Gap report or standardization tracker | **Partial** — gap report shipped (T03); tracker deferred | | 28 | Registry maintenance automation | Interactive `maintain` + `--auto` with llm-connect | **Closed** (WP-0016) | +| 29 | Consumption loop (query-before-build) | `plan-check` command + State Hub capability-request bridge | **Partial** — deterministic matching (T02) and the request bridge (T04) shipped; LLM semantic rerank (T03) and ecosystem session-protocol rollout (T05) remain open, T05 gated on REUSE-WP-0017 coverage reaching the *published* federated index, not just local drafts | **Workplan:** `workplans/REUSE-WP-0016-interactive-registry-maintain.md` (priority 28); -`workplans/REUSE-WP-0015-federation-polish-and-planning-analytics.md` (25–27) +`workplans/REUSE-WP-0015-federation-polish-and-planning-analytics.md` (25–27); +`workplans/REUSE-WP-0018-plan-check-consumption-loop.md` (priority 29) **Assessment:** `history/2026-06-16-intent-scope-assessment.md` **Follow-up docs:** @@ -235,4 +237,5 @@ See §4 and archived workplans `workplans/archived/`. | 2026-06-16 | WP-0014 closed priority 18; 60 workstation repos | | 2026-06-16 | **SCOPE refresh + full INTENT success-criteria mapping**; priorities 25–27 proposed | | 2026-06-16 | Assessment persisted; **REUSE-WP-0015** created for priorities 25–27 | -| 2026-06-16 | **REUSE-WP-0016** closed priority 28 (interactive `maintain`, `--auto`, templates) | \ No newline at end of file +| 2026-06-16 | **REUSE-WP-0016** closed priority 28 (interactive `maintain`, `--auto`, templates) | +| 2026-07-07 | **REUSE-WP-0018** partially closed priority 29 (`plan-check` deterministic matching + State Hub capability-request bridge; LLM rerank and ecosystem rollout remain open) | \ No newline at end of file diff --git a/docs/RegistryFederation.md b/docs/RegistryFederation.md index befdebf..26327a2 100644 --- a/docs/RegistryFederation.md +++ b/docs/RegistryFederation.md @@ -297,6 +297,23 @@ Register published raw index URLs on the hub, or enable a `url` source in endpoint requires a token. Agents without sibling repo clones can discover capabilities from the hub or from HTTP sources plus the local index. +### Query before building (`plan-check`) + +Before drafting a new workplan, run `reuse-surface plan-check` (REUSE-WP-0018) +against a draft workplan file or a free-text intent. It matches your intent +against `registry/indexes/federated.yaml` and returns a `reuse | extend | new` +verdict — advisory only, never a gate: + +```bash +reuse-surface plan-check workplans/XXX-WP-NNNN-something.md +reuse-surface plan-check --intent "parse invoices and file evidence" +``` + +On a `new` verdict, `--file-request` bridges the gap to a State Hub +capability request so other domains can see the unmet need; `report gaps +--check-capability-requests` surfaces open requests with no matching +federated capability. See `specs/PlanCheck.md` for the full design. + ## Relation graphs ```bash diff --git a/reuse_surface/cli.py b/reuse_surface/cli.py index 5591d7c..7b7205c 100644 --- a/reuse_surface/cli.py +++ b/reuse_surface/cli.py @@ -20,6 +20,16 @@ from reuse_surface.hub_sync import ( write_sources_manifest, ) from reuse_surface.overlaps import find_overlaps +from reuse_surface.statehub_bridge import list_open_capability_requests +from reuse_surface.plan_check import ( + format_plan_check_json, + format_plan_check_markdown, + load_query_from_intent, + load_query_from_workplan, + maybe_file_capability_request, + record_outcome, + run_plan_check, +) from reuse_surface.reports import ( cohort_filters_from_args, collect_gap_report, @@ -578,16 +588,85 @@ def cmd_report_cohorts(args: argparse.Namespace) -> int: return 0 +def cmd_plan_check(args: argparse.Namespace) -> int: + if args.workplan and args.intent: + print("error: pass either a workplan file or --intent, not both", file=sys.stderr) + return 1 + if args.workplan: + path = Path(args.workplan).resolve() + if not path.exists(): + print(f"error: workplan not found: {path}", file=sys.stderr) + return 1 + query = load_query_from_workplan(path) + elif args.intent: + query = load_query_from_intent(args.intent) + else: + print("error: provide a workplan file or --intent", file=sys.stderr) + return 1 + + result = run_plan_check( + query, + reuse_threshold=args.reuse_threshold, + extend_threshold=args.extend_threshold, + ) + + if args.record_outcome: + record_outcome(result, args.record_outcome, consumer_repo=args.consumer_repo) + + filed = None + if args.file_request and result["verdict"] == "new": + filed = maybe_file_capability_request( + result, + requesting_domain=args.requesting_domain, + requesting_agent=args.consumer_repo, + ) + + if args.format == "json": + if filed is not None: + result["filed_capability_request"] = filed + print(format_plan_check_json(result)) + else: + print(format_plan_check_markdown(result), end="") + if args.file_request and result["verdict"] == "new": + if filed: + print(f"\nFiled State Hub capability request: {filed.get('id')}") + else: + print("\nState Hub unreachable — capability request not filed.") + return 0 + + def cmd_report_gaps(args: argparse.Namespace) -> int: roster_path = Path(args.roster).resolve() if args.roster else default_roster_path() if not roster_path.exists(): print(f"error: roster not found: {roster_path}", file=sys.stderr) return 1 report = collect_gap_report(roster_path) + + if args.check_capability_requests: + open_requests = list_open_capability_requests() + report["open_capability_requests"] = ( + [ + {"id": r["id"], "title": r["title"], "requesting_domain": r.get("requesting_domain_slug")} + for r in open_requests + ] + if open_requests is not None + else None + ) + if args.format == "json": print(format_gap_json(report)) else: print(format_gap_markdown(report), end="") + if args.check_capability_requests: + requests_ = report["open_capability_requests"] + print("\n## Open State Hub capability requests (no matching federated capability)\n") + if requests_ is None: + print("_State Hub unreachable — skipped._\n") + elif not requests_: + print("- none\n") + else: + for r in requests_: + print(f"- `{r['id']}` ({r['requesting_domain']}): {r['title']}") return 0 @@ -697,6 +776,44 @@ def main(argv: list[str] | None = None) -> int: ) overlaps.set_defaults(func=cmd_overlaps) + plan_check = subparsers.add_parser( + "plan-check", + help="match a draft workplan or intent against the federated capability index", + ) + plan_check.add_argument( + "workplan", nargs="?", help="path to a workplan file (frontmatter + body)" + ) + plan_check.add_argument("--intent", help="free-text intent instead of a workplan file") + plan_check.add_argument( + "--format", choices=["markdown", "json"], default="markdown" + ) + plan_check.add_argument( + "--reuse-threshold", type=float, default=0.45, + help="score at or above which the verdict is 'reuse'", + ) + plan_check.add_argument( + "--extend-threshold", type=float, default=0.22, + help="score at or above which the verdict is 'extend'", + ) + plan_check.add_argument( + "--record-outcome", + choices=["reused", "extended", "new", "skipped"], + help="append this outcome to registry/telemetry/plan-check-events.jsonl", + ) + plan_check.add_argument( + "--consumer-repo", default="reuse-surface", + help="repo slug recorded with --record-outcome and --file-request", + ) + plan_check.add_argument( + "--file-request", action="store_true", + help="on a 'new' verdict, file a State Hub capability request for the gap", + ) + plan_check.add_argument( + "--requesting-domain", default="infotech", + help="domain slug recorded on a filed capability request", + ) + plan_check.set_defaults(func=cmd_plan_check) + catalog = subparsers.add_parser( "catalog", help="generate human-readable capability catalog" ) @@ -808,6 +925,12 @@ def main(argv: list[str] | None = None) -> int: choices=["markdown", "json"], default="markdown", ) + gaps.add_argument( + "--check-capability-requests", + action="store_true", + help="also list open State Hub capability requests with no matching " + "federated capability (requires State Hub reachable at 127.0.0.1:8000)", + ) gaps.set_defaults(func=cmd_report_gaps) stats = subparsers.add_parser("stats", help="registry maturity and federation stats") diff --git a/reuse_surface/plan_check.py b/reuse_surface/plan_check.py new file mode 100644 index 0000000..00dfd31 --- /dev/null +++ b/reuse_surface/plan_check.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + +from reuse_surface.federation import FEDERATED_INDEX_PATH +from reuse_surface.overlaps import TOKEN_RE +from reuse_surface.registry import ROOT, load_index +from reuse_surface.statehub_bridge import file_capability_request + +TELEMETRY_PATH = ROOT / "registry" / "telemetry" / "plan-check-events.jsonl" + +STALE_DAYS = 14 +DEFAULT_REUSE_THRESHOLD = 0.45 +DEFAULT_EXTEND_THRESHOLD = 0.22 +DEFAULT_TIE_WINDOW = 0.05 + + +@dataclass +class MatchQuery: + source: str + text: str + workplan_id: str | None = None + workplan_path: str | None = None + tokens: set[str] = field(default_factory=set) + + +@dataclass +class Match: + id: str + score: float + kind: str + vector: str | None = None + owner: str | None = None + summary: str | None = None + + +def _tokens(text: str) -> set[str]: + return set(TOKEN_RE.findall(text.lower())) + + +def load_query_from_workplan(path: Path) -> MatchQuery: + text = path.read_text(encoding="utf-8") + match = re.match(r"^---\n(.*?)\n---\n?(.*)$", text, re.DOTALL) + if not match: + raise ValueError(f"{path}: missing YAML front matter") + front = yaml.safe_load(match.group(1)) or {} + body = match.group(2) + + parts = [str(front.get("title") or front.get("id") or path.stem)] + for heading in ("Core Idea", "Problem statement", "One-liner"): + section = re.search( + rf"^##\s+{re.escape(heading)}\s*\n(.*?)(?:\n##\s|\Z)", + body, + re.DOTALL | re.MULTILINE, + ) + if section: + parts.append(section.group(1).strip()) + if len(parts) == 1: + intro = body.strip().split("\n\n", 1)[0] + parts.append(intro) + + blob = "\n".join(p for p in parts if p) + return MatchQuery( + source="workplan", + text=blob, + workplan_id=front.get("id"), + workplan_path=str(path), + tokens=_tokens(blob), + ) + + +def load_query_from_intent(intent: str) -> MatchQuery: + return MatchQuery(source="intent", text=intent, tokens=_tokens(intent)) + + +def _federated_entry_blob(item: dict[str, Any]) -> str: + parts = [ + item.get("name", ""), + item.get("summary", ""), + " ".join(item.get("tags", [])), + ] + return " ".join(str(p) for p in parts if p) + + +def load_federated_capabilities() -> tuple[list[dict[str, Any]], str | None]: + """Returns (capabilities, updated_date). Falls back to the local index + if the composed federated index doesn't exist yet.""" + if FEDERATED_INDEX_PATH.exists(): + data = yaml.safe_load(FEDERATED_INDEX_PATH.read_text(encoding="utf-8")) + return data.get("capabilities", []), data.get("updated") + data = load_index() + return data.get("capabilities", []), data.get("updated") + + +def _staleness_warning(updated: str | None) -> str | None: + if not updated: + return None + try: + updated_date = datetime.strptime(updated, "%Y-%m-%d").replace( + tzinfo=timezone.utc + ) + except ValueError: + return None + age_days = (datetime.now(timezone.utc) - updated_date).days + if age_days > STALE_DAYS: + return f"federated index is {age_days} days old (last composed {updated})" + return None + + +def _vector_rank(vector: str | None) -> tuple[int, int]: + """Higher discovery/availability levels rank first among near-tied scores.""" + if not vector: + return (0, 0) + m = re.match(r"D(\d+)\s*/\s*A(\d+)", vector) + if not m: + return (0, 0) + return (int(m.group(1)), int(m.group(2))) + + +def match_query( + query: MatchQuery, + capabilities: list[dict[str, Any]], + *, + tie_window: float = DEFAULT_TIE_WINDOW, +) -> list[Match]: + if not query.tokens or not capabilities: + return [] + + scored: list[Match] = [] + for item in capabilities: + blob = _federated_entry_blob(item) + tokens = _tokens(blob) + if not tokens: + continue + score = len(query.tokens & tokens) / len(query.tokens | tokens) + if score <= 0: + continue + scored.append( + Match( + id=item["id"], + score=round(score, 4), + kind="deterministic", + vector=item.get("vector"), + owner=item.get("owner"), + summary=item.get("summary"), + ) + ) + + if not scored: + return [] + + scored.sort(key=lambda m: m.score, reverse=True) + top_score = scored[0].score + tied = [m for m in scored if top_score - m.score <= tie_window] + rest = [m for m in scored if top_score - m.score > tie_window] + tied.sort(key=lambda m: (_vector_rank(m.vector), m.score), reverse=True) + return tied + rest + + +def verdict_for_score( + top_score: float, + *, + reuse_threshold: float = DEFAULT_REUSE_THRESHOLD, + extend_threshold: float = DEFAULT_EXTEND_THRESHOLD, +) -> str: + if top_score >= reuse_threshold: + return "reuse" + if top_score >= extend_threshold: + return "extend" + return "new" + + +def run_plan_check( + query: MatchQuery, + *, + reuse_threshold: float = DEFAULT_REUSE_THRESHOLD, + extend_threshold: float = DEFAULT_EXTEND_THRESHOLD, + tie_window: float = DEFAULT_TIE_WINDOW, + top_n: int = 5, +) -> dict[str, Any]: + capabilities, updated = load_federated_capabilities() + matches = match_query(query, capabilities, tie_window=tie_window) + top_score = matches[0].score if matches else 0.0 + verdict = verdict_for_score( + top_score, reuse_threshold=reuse_threshold, extend_threshold=extend_threshold + ) + return { + "query": { + "source": query.source, + "text": query.text, + "workplan_id": query.workplan_id, + "workplan_path": query.workplan_path, + }, + "verdict": verdict, + "top_score": top_score, + "matches": [ + { + "id": m.id, + "score": m.score, + "vector": m.vector, + "owner": m.owner, + "summary": m.summary, + "kind": m.kind, + } + for m in matches[:top_n] + ], + "federated_index_updated": updated, + "federated_index_stale_warning": _staleness_warning(updated), + } + + +def format_plan_check_markdown(result: dict[str, Any]) -> str: + lines = [f"# Plan check: {result['verdict']}", ""] + query = result["query"] + label = query.get("workplan_id") or query["text"][:80] + lines.append(f"**Query ({query['source']}):** {label}") + lines.append("") + + matches = result.get("matches", []) + if matches: + lines.append("## Matches") + for m in matches: + vec = f" ({m['vector']})" if m.get("vector") else "" + owner = f" — {m['owner']}" if m.get("owner") else "" + lines.append(f"- `{m['id']}`{vec}{owner} — score {m['score']:.2f} [{m['kind']}]") + if m.get("summary"): + lines.append(f" > {m['summary']}") + else: + lines.append("_No matches found in the federated index._") + lines.append("") + + verdict = result["verdict"] + if verdict == "reuse": + lines.append("**Verdict: REUSE** — an existing capability already covers this; link it instead of building new.") + elif verdict == "extend": + lines.append("**Verdict: EXTEND** — scope overlaps closely enough that extending the top match is likely cheaper than a new capability.") + else: + lines.append("**Verdict: NEW** — no close match in the federated index; proceed.") + + warning = result.get("federated_index_stale_warning") + if warning: + lines.append("") + lines.append(f"⚠ {warning}") + + return "\n".join(lines) + "\n" + + +def format_plan_check_json(result: dict[str, Any]) -> str: + return json.dumps(result, indent=2, sort_keys=True) + + +def maybe_file_capability_request( + result: dict[str, Any], + *, + requesting_domain: str, + requesting_agent: str, +) -> dict[str, Any] | None: + """On a 'new' verdict, file a State Hub capability request for the gap. + Returns None (and does nothing) for any other verdict, or if the hub is + unreachable -- this never blocks plan-check's primary output.""" + if result["verdict"] != "new": + return None + query = result["query"] + title = (query.get("workplan_id") or query["text"])[:120] + return file_capability_request( + title=f"plan-check gap: {title}", + description=query["text"], + requesting_domain=requesting_domain, + requesting_agent=requesting_agent, + requesting_workplan_id=query.get("workplan_id"), + ) + + +def record_outcome( + result: dict[str, Any], + outcome: str, + *, + consumer_repo: str = "reuse-surface", +) -> Path: + TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True) + top_match = result["matches"][0]["id"] if result.get("matches") else None + event = { + "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "consumer_repo": consumer_repo, + "capability_id": top_match, + "verdict": result["verdict"], + "outcome": outcome, + "source": "plan-check", + } + with TELEMETRY_PATH.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, sort_keys=True) + "\n") + return TELEMETRY_PATH diff --git a/reuse_surface/statehub_bridge.py b/reuse_surface/statehub_bridge.py new file mode 100644 index 0000000..df4d268 --- /dev/null +++ b/reuse_surface/statehub_bridge.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +DEFAULT_BASE_URL = "http://127.0.0.1:8000" +TIMEOUT_SECONDS = 5 +LIST_TIMEOUT_SECONDS = 20 + +# Observed status vocabulary (2026-07): "requested", "completed". No fixed +# enum is published by the API (free-form string field) -- treat anything +# not in this terminal set as still-open rather than assuming a closed list. +TERMINAL_STATUSES = {"completed", "cancelled", "rejected", "resolved", "closed"} + + +def _request( + method: str, + path: str, + base_url: str, + *, + body: dict[str, Any] | None = None, + params: dict[str, str] | None = None, + timeout: int = TIMEOUT_SECONDS, +) -> dict[str, Any] | list[Any]: + url = f"{base_url.rstrip('/')}{path}" + if params: + query = "&".join( + f"{k}={urllib.parse.quote(v)}" for k, v in params.items() if v + ) + if query: + url = f"{url}?{query}" + data = json.dumps(body).encode("utf-8") if body is not None else None + request = urllib.request.Request( + url, + data=data, + method=method, + headers={"Content-Type": "application/json", "User-Agent": "reuse-surface/0.1"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def state_hub_reachable(base_url: str = DEFAULT_BASE_URL) -> bool: + try: + _request("GET", "/state/health", base_url) + return True + except (urllib.error.URLError, TimeoutError, OSError): + return False + + +def file_capability_request( + *, + title: str, + description: str, + requesting_domain: str, + requesting_agent: str, + capability_type: str = "reuse-surface-new-verdict", + requesting_workplan_id: str | None = None, + base_url: str = DEFAULT_BASE_URL, +) -> dict[str, Any] | None: + """Files a State Hub capability request for a plan-check 'new' verdict. + + Returns the created request record, or None if the hub is unreachable + (degrades gracefully -- plan-check never blocks on this).""" + if not state_hub_reachable(base_url): + return None + body = { + "title": title, + "description": description, + "capability_type": capability_type, + "requesting_domain": requesting_domain, + "requesting_agent": requesting_agent, + "requesting_workplan_id": requesting_workplan_id, + } + try: + return _request("POST", "/capability-requests/", base_url, body=body) + except (urllib.error.URLError, TimeoutError, OSError): + return None + + +def list_open_capability_requests( + base_url: str = DEFAULT_BASE_URL, +) -> list[dict[str, Any]] | None: + """Returns capability requests whose status is not terminal (see + TERMINAL_STATUSES), or None if the hub is unreachable. A request can + carry a catalog_entry_id while still 'requested' (routed, not + fulfilled), so status -- not catalog_entry_id presence -- is the open + signal. The list endpoint can be slow (observed ~7s with a handful of + rows); a longer timeout than the health check is used deliberately.""" + if not state_hub_reachable(base_url): + return None + try: + requests_ = _request( + "GET", "/capability-requests/", base_url, timeout=LIST_TIMEOUT_SECONDS + ) + except (urllib.error.URLError, TimeoutError, OSError): + return None + if not isinstance(requests_, list): + return [] + return [r for r in requests_ if r.get("status") not in TERMINAL_STATUSES] diff --git a/schemas/plan-check-result.schema.json b/schemas/plan-check-result.schema.json new file mode 100644 index 0000000..5c41c6e --- /dev/null +++ b/schemas/plan-check-result.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://reuse-surface.local/schemas/plan-check-result.schema.json", + "title": "PlanCheckResult", + "type": "object", + "additionalProperties": false, + "required": ["query", "verdict", "top_score", "matches"], + "properties": { + "query": { + "type": "object", + "additionalProperties": false, + "required": ["source", "text"], + "properties": { + "source": {"type": "string", "enum": ["workplan", "intent"]}, + "text": {"type": "string", "minLength": 1}, + "workplan_id": {"type": ["string", "null"]}, + "workplan_path": {"type": ["string", "null"]} + } + }, + "verdict": { + "type": "string", + "enum": ["reuse", "extend", "new"] + }, + "top_score": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "matches": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "score", "kind"], + "properties": { + "id": { + "type": "string", + "pattern": "^capability\\.[a-z0-9]+(\\.[a-z0-9-]+)+$" + }, + "score": {"type": "number", "minimum": 0, "maximum": 1}, + "vector": {"type": ["string", "null"]}, + "owner": {"type": ["string", "null"]}, + "summary": {"type": ["string", "null"]}, + "kind": { + "type": "string", + "enum": ["deterministic", "llm", "related"] + } + } + } + }, + "federated_index_updated": {"type": ["string", "null"]}, + "federated_index_stale_warning": {"type": ["string", "null"]} + } +} diff --git a/schemas/reuse-event.schema.json b/schemas/reuse-event.schema.json new file mode 100644 index 0000000..cb1ebdb --- /dev/null +++ b/schemas/reuse-event.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://reuse-surface.local/schemas/reuse-event.schema.json", + "title": "ReuseEvent", + "description": "One line of registry/telemetry/plan-check-events.jsonl, or one POST /v1/reuse-events body (REUSE-WP-0019-T04). Shared schema so plan-check's local fallback and the hub's telemetry store never diverge.", + "type": "object", + "additionalProperties": false, + "required": ["ts", "consumer_repo", "verdict", "source"], + "properties": { + "ts": {"type": "string", "format": "date-time"}, + "consumer_repo": {"type": "string", "minLength": 1}, + "capability_id": { + "type": ["string", "null"], + "pattern": "^capability\\.[a-z0-9]+(\\.[a-z0-9-]+)+$" + }, + "verdict": { + "type": "string", + "enum": ["reuse", "extend", "new"] + }, + "outcome": { + "type": ["string", "null"], + "enum": ["reused", "extended", "new", "skipped", null] + }, + "source": { + "type": "string", + "enum": ["plan-check", "manual", "hub"] + } + } +} diff --git a/specs/PlanCheck.md b/specs/PlanCheck.md new file mode 100644 index 0000000..80b1df4 --- /dev/null +++ b/specs/PlanCheck.md @@ -0,0 +1,169 @@ +# Plan Check + +**Repository:** `reuse-surface` +**Artifact:** `specs/PlanCheck.md` +**Status:** Draft 0.1 (REUSE-WP-0018-T01) +**Schema:** `schemas/plan-check-result.schema.json` + +--- + +## 1. Purpose + +`plan-check` closes the consumption loop the registry has lacked since +inception: nothing today nudges an agent planning work in a sibling repo to +query the federated capability index before building. The registry has been +write-mostly. `plan-check` is the query-before-build step — advisory, not a +gate — that matches a draft workplan or a free-text intent against the +federated index and returns a verdict: reuse it, extend it, or it's genuinely +new. + +This spec covers the deterministic matching core (T02) and its interfaces +with the optional LLM rerank (T03) and the State Hub capability-request +bridge (T04). It does not cover the ecosystem rollout (T05) — the +session-protocol convention is designed once this spec is implemented and +dogfooded in this repo. + +## 2. Design principles + +1. **Deterministic core, LLM assist optional.** Keyword/scope/relation + matching against `registry/indexes/federated.yaml` works with no external + dependency. `LLM_CONNECT_URL` unlocks a semantic rerank pass; its absence + degrades gracefully, never blocks. +2. **Advisory, not blocking.** `plan-check` never fails a workplan into + existence or refuses to let one be created. Adoption comes from the tool + being useful and from convention (REUSE-WP-0018-T05), not from a gate. +3. **Deterministic matches always rank first.** Whatever the LLM rerank + proposes, it is listed after — and clearly labeled apart from — the + deterministic candidates, so a human or agent can trust the base result + even with `--no-llm`. +4. **Every invocation is a data point.** `--record-outcome` writes an + append-only fact. This is the raw material for REUSE-WP-0019's reuse + telemetry — `plan-check` and telemetry share one event schema from day + one so nothing needs migrating later. + +## 3. Input model + +Two input shapes, mutually exclusive: + +| Input | How it's read | +|---|---| +| Workplan file | Parsed like `registry_update.py`'s git-diff signal collector: YAML frontmatter (`---`-delimited) plus the Markdown body. `title`, the one-liner in the intro paragraph, and any `## Problem statement` / `## Core Idea` heading content feed the match blob. | +| `--intent "free text"` | Used verbatim as the match blob. | + +Both normalize to the same internal `MatchQuery { text: str, tokens: set[str] }` +before matching — the matcher does not care which input shape it came from. + +## 4. Matching pipeline + +Reuses the token-Jaccard approach already proven in `overlaps.py` +(`TOKEN_RE`, `_tokens`) rather than inventing a second scoring method in the +same codebase: + +1. **Tokenize** the query blob with the existing `TOKEN_RE`. +2. **Score** against every federated capability's blob (`name` + `summary` + + `tags` + `discovery.intent` + `discovery.includes`, mirroring + `overlaps._entry_blob`) via Jaccard similarity. +3. **Rank signal, not just tie-break:** among candidates within + `--tie-window` (default 0.05) of the top score, prefer the entry with the + higher discovery/availability vector (a more mature capability is a safer + reuse bet at equal textual match). +4. **Relation expansion:** if a top candidate has `relations.supports` or + `relations.related_to` entries, surface them as secondary candidates + labeled `related` rather than silently dropped. + +## 5. Verdict model + +| Verdict | Condition | Meaning | +|---|---|---| +| `reuse` | top score ≥ `--reuse-threshold` (default 0.45) | An existing capability already covers this need — link it, don't rebuild it. | +| `extend` | top score in `[--extend-threshold, --reuse-threshold)` (default 0.22–0.45) | Scope overlaps a capability closely enough that extending it is very likely cheaper than a new one — surfaced with an explicit "consider extending" framing. | +| `new` | top score < `--extend-threshold`, or no federated capabilities exist | No good match. Proceed; optionally file a capability request (T04). | + +Thresholds are CLI flags, not hardcoded, because the right cutoff will drift +as coverage and entry quality improve (see REUSE-WP-0017). Defaults come from +the same threshold reasoning as `overlaps.py`'s `--threshold 0.28` default, +shifted since plan-check matches short intent text against short summaries +rather than long entry-to-entry blobs. + +## 6. Output + +### Markdown (TTY default) + +```text +# Plan check: reuse | extend | new + +**Query:** + +## Top match +- `capability.infotech.issue-tracking` (score 0.52, D4/A2/C2/R1) — issue-core + > Unified Python/CLI interface for issue tracking across Gitea, GitHub, and GitLab... + +## Other candidates +- ... + +## Related (via relations.*) +- ... + +Verdict: REUSE — link this capability instead of building new. +``` + +### JSON (`--format json`, agent-consumable) + +Validated against `schemas/plan-check-result.schema.json`: + +```json +{ + "query": {"source": "workplan|intent", "text": "...", "workplan_id": "..."}, + "verdict": "reuse|extend|new", + "top_score": 0.52, + "matches": [ + {"id": "capability.infotech.issue-tracking", "score": 0.52, "vector": "D4/A2/C2/R1", "owner": "issue-core", "kind": "deterministic|llm|related"} + ], + "federated_index_updated": "2026-07-06", + "federated_index_stale_warning": null +} +``` + +`federated_index_stale_warning` is set when `federated.yaml`'s `updated` +field is older than 14 days — a cheap freshness signal until +REUSE-WP-0019's automatic recompose lands. + +## 7. Outcome recording (`--record-outcome`) + +Append-only JSONL under `registry/telemetry/plan-check-events.jsonl` +(reuse-surface's own copy; sibling repos get their own via the same schema +when they adopt T05). One line per invocation: + +```json +{"ts": "2026-07-07T10:00:00Z", "consumer_repo": "reuse-surface", "capability_id": "capability.infotech.issue-tracking", "verdict": "reuse", "outcome": "reused|extended|new|skipped", "source": "plan-check"} +``` + +This schema is deliberately identical to the reuse-event schema +REUSE-WP-0019-T04 will implement server-side — `plan-check`'s local JSONL +file is the fallback path when the hub is unreachable, and the same record +shape posts to `POST /v1/reuse-events` once T04/WP-0019 exist. No local +telemetry write ever blocks the primary command. + +## 8. Relationship to T03 (LLM rerank) and T04 (State Hub bridge) + +- **T03** adds an optional post-pass: send the deterministic top-N candidates + plus the query text to `llm-connect` for a semantic confidence score and + possible reordering *within* the deterministic candidate set. It does not + invent new candidates outside what deterministic matching already found — + keeps the "deterministic matches always rank first" guarantee simple to + reason about. Schema-constrained JSON response, mirrors the + `maintain_llm.py` pattern (graceful skip on missing `LLM_CONNECT_URL`, + reject malformed responses rather than guess). +- **T04** wires `new` verdicts to `POST /messages/` (State Hub + `request_capability`-equivalent) and reads back open capability requests + for `report gaps` to list unmatched. Deferred to its own task since it + depends on State Hub API shapes this spec does not need to pin down yet. + +## 9. Non-goals + +- Blocking or gating workplan creation (§2.2). +- Embedding-based / vector-similarity matching — token-Jaccard is the + deterministic core for consistency with `overlaps.py`; embeddings are an + LLM-rerank concern (T03), not a second deterministic path. +- Editing sibling repos' rules files directly — that's the template + propagation mechanism's job (T05), not this tool's. diff --git a/tests/test_plan_check.py b/tests/test_plan_check.py new file mode 100644 index 0000000..b6497e1 --- /dev/null +++ b/tests/test_plan_check.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import json + +from reuse_surface.plan_check import ( + MatchQuery, + load_query_from_intent, + load_query_from_workplan, + maybe_file_capability_request, + match_query, + record_outcome, + run_plan_check, + verdict_for_score, +) + +SAMPLE_CAPABILITIES = [ + { + "id": "capability.infotech.issue-tracking", + "name": "Universal Issue Tracking Coordination", + "summary": "Unified interface for issue tracking coordination across Gitea, GitHub, GitLab.", + "vector": "D4 / A2 / C2 / R1", + "owner": "issue-core", + "tags": ["issue-tracking", "coordination"], + }, + { + "id": "capability.audit.event-retain", + "name": "Audit Event Retention", + "summary": "Collect, normalize, retain, and search audit events with integrity evidence across tenants.", + "vector": "D4 / A2 / C2 / R1", + "owner": "audit-core", + "tags": ["audit"], + }, +] + + +def test_verdict_thresholds(): + assert verdict_for_score(0.5) == "reuse" + assert verdict_for_score(0.3) == "extend" + assert verdict_for_score(0.1) == "new" + assert verdict_for_score(0.45) == "reuse" + assert verdict_for_score(0.22) == "extend" + + +def test_match_query_finds_close_match(): + query = load_query_from_intent( + "unified issue tracking coordination across Gitea GitHub GitLab" + ) + matches = match_query(query, SAMPLE_CAPABILITIES) + assert matches + assert matches[0].id == "capability.infotech.issue-tracking" + assert matches[0].score > 0.3 + + +def test_match_query_empty_when_no_overlap(): + query = load_query_from_intent("something entirely unrelated xyzzy plugh") + matches = match_query(query, SAMPLE_CAPABILITIES) + assert matches == [] + + +def test_match_query_no_capabilities(): + query = load_query_from_intent("anything") + assert match_query(query, []) == [] + + +def test_load_query_from_workplan(tmp_path): + workplan = tmp_path / "TEST-WP-0001-thing.md" + workplan.write_text( + """--- +id: TEST-WP-0001 +title: "Unified issue tracking coordination" +status: proposed +--- + +# Unified issue tracking coordination + +## Core Idea + +Build a single interface for issue tracking across Gitea, GitHub, and GitLab. +""" + ) + query = load_query_from_workplan(workplan) + assert query.source == "workplan" + assert query.workplan_id == "TEST-WP-0001" + assert "issue tracking" in query.text.lower() + matches = match_query(query, SAMPLE_CAPABILITIES) + assert matches[0].id == "capability.infotech.issue-tracking" + + +def test_run_plan_check_reuse_verdict(monkeypatch): + monkeypatch.setattr( + "reuse_surface.plan_check.load_federated_capabilities", + lambda: (SAMPLE_CAPABILITIES, "2026-07-06"), + ) + query = MatchQuery( + source="intent", + text="unified issue tracking coordination gitea github gitlab", + tokens={"unified", "issue", "tracking", "coordination", "gitea", "github", "gitlab"}, + ) + result = run_plan_check(query) + assert result["verdict"] == "reuse" + assert result["matches"][0]["id"] == "capability.infotech.issue-tracking" + assert result["federated_index_stale_warning"] is None + + +def test_run_plan_check_stale_warning(monkeypatch): + monkeypatch.setattr( + "reuse_surface.plan_check.load_federated_capabilities", + lambda: (SAMPLE_CAPABILITIES, "2020-01-01"), + ) + query = load_query_from_intent("nothing matches this at all zzz") + result = run_plan_check(query) + assert result["verdict"] == "new" + assert "days old" in result["federated_index_stale_warning"] + + +def test_record_outcome_appends_jsonl(tmp_path, monkeypatch): + telemetry_path = tmp_path / "plan-check-events.jsonl" + monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path) + result = { + "verdict": "reuse", + "matches": [{"id": "capability.infotech.issue-tracking"}], + } + record_outcome(result, "reused", consumer_repo="some-repo") + lines = telemetry_path.read_text().splitlines() + assert len(lines) == 1 + event = json.loads(lines[0]) + assert event["consumer_repo"] == "some-repo" + assert event["capability_id"] == "capability.infotech.issue-tracking" + assert event["outcome"] == "reused" + assert event["source"] == "plan-check" + + +def test_maybe_file_capability_request_only_on_new_verdict(monkeypatch): + called = [] + monkeypatch.setattr( + "reuse_surface.plan_check.file_capability_request", + lambda **kwargs: called.append(kwargs) or {"id": "req-1"}, + ) + reuse_result = {"verdict": "reuse", "query": {"text": "x", "workplan_id": None}} + assert maybe_file_capability_request( + reuse_result, requesting_domain="infotech", requesting_agent="a" + ) is None + assert called == [] + + new_result = {"verdict": "new", "query": {"text": "some new intent", "workplan_id": None}} + filed = maybe_file_capability_request( + new_result, requesting_domain="infotech", requesting_agent="a" + ) + assert filed == {"id": "req-1"} + assert called[0]["requesting_domain"] == "infotech" + + +def test_cmd_plan_check_file_request_flag(monkeypatch): + from reuse_surface.cli import main + + monkeypatch.setattr( + "reuse_surface.plan_check.load_federated_capabilities", + lambda: (SAMPLE_CAPABILITIES, "2026-07-06"), + ) + filed_calls = [] + monkeypatch.setattr( + "reuse_surface.cli.maybe_file_capability_request", + lambda result, **kwargs: filed_calls.append(kwargs) or {"id": "req-9"}, + ) + exit_code = main( + ["plan-check", "--intent", "totally unrelated xyzzy plugh", "--file-request"] + ) + assert exit_code == 0 + assert filed_calls + + +def test_cmd_plan_check_intent_json(monkeypatch): + from reuse_surface.cli import main + + monkeypatch.setattr( + "reuse_surface.plan_check.load_federated_capabilities", + lambda: (SAMPLE_CAPABILITIES, "2026-07-06"), + ) + exit_code = main( + ["plan-check", "--intent", "issue tracking gitea github gitlab", "--format", "json"] + ) + assert exit_code == 0 + + +def test_cmd_plan_check_requires_input(): + from reuse_surface.cli import main + + exit_code = main(["plan-check"]) + assert exit_code == 1 + + +def test_cmd_plan_check_rejects_both_inputs(tmp_path): + from reuse_surface.cli import main + + workplan = tmp_path / "x.md" + workplan.write_text("---\nid: X\n---\nbody") + exit_code = main(["plan-check", str(workplan), "--intent", "also this"]) + assert exit_code == 1 diff --git a/tests/test_reports.py b/tests/test_reports.py index a68d5be..8f0aefd 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -158,6 +158,29 @@ def test_cmd_report_gaps_json(monkeypatch): assert exit_code == 0 +def test_cmd_report_gaps_check_capability_requests_unreachable(monkeypatch): + from reuse_surface.cli import main + + monkeypatch.setattr( + "reuse_surface.cli.list_open_capability_requests", lambda: None + ) + exit_code = main(["report", "gaps", "--check-capability-requests"]) + assert exit_code == 0 + + +def test_cmd_report_gaps_check_capability_requests_json(monkeypatch): + from reuse_surface.cli import main + + monkeypatch.setattr( + "reuse_surface.cli.list_open_capability_requests", + lambda: [{"id": "r1", "title": "Need X", "requesting_domain_slug": "infotech"}], + ) + exit_code = main( + ["report", "gaps", "--check-capability-requests", "--format", "json"] + ) + assert exit_code == 0 + + def test_cmd_report_cohorts_markdown(monkeypatch): from reuse_surface.cli import main diff --git a/tests/test_statehub_bridge.py b/tests/test_statehub_bridge.py new file mode 100644 index 0000000..fd8378e --- /dev/null +++ b/tests/test_statehub_bridge.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import urllib.error + +from reuse_surface import statehub_bridge + + +def test_state_hub_reachable_true(monkeypatch): + monkeypatch.setattr(statehub_bridge, "_request", lambda *a, **k: {"status": "ok"}) + assert statehub_bridge.state_hub_reachable() is True + + +def test_state_hub_reachable_false_on_error(monkeypatch): + def raise_error(*args, **kwargs): + raise urllib.error.URLError("no route") + + monkeypatch.setattr(statehub_bridge, "_request", raise_error) + assert statehub_bridge.state_hub_reachable() is False + + +def test_file_capability_request_skips_when_unreachable(monkeypatch): + monkeypatch.setattr(statehub_bridge, "state_hub_reachable", lambda base_url=None: False) + result = statehub_bridge.file_capability_request( + title="t", description="d", requesting_domain="infotech", requesting_agent="a" + ) + assert result is None + + +def test_file_capability_request_posts_body(monkeypatch): + captured = {} + + def fake_request(method, path, base_url, *, body=None, params=None, timeout=None): + captured["method"] = method + captured["path"] = path + captured["body"] = body + return {"id": "abc-123", **body} + + monkeypatch.setattr(statehub_bridge, "state_hub_reachable", lambda base_url=None: True) + monkeypatch.setattr(statehub_bridge, "_request", fake_request) + result = statehub_bridge.file_capability_request( + title="Need X", description="desc", requesting_domain="infotech", requesting_agent="reuse-surface" + ) + assert captured["method"] == "POST" + assert captured["path"] == "/capability-requests/" + assert captured["body"]["title"] == "Need X" + assert result["id"] == "abc-123" + + +def test_list_open_capability_requests_filters_terminal_status(monkeypatch): + monkeypatch.setattr(statehub_bridge, "state_hub_reachable", lambda base_url=None: True) + monkeypatch.setattr( + statehub_bridge, + "_request", + lambda *a, **k: [ + {"id": "1", "status": "requested"}, + {"id": "2", "status": "completed"}, + {"id": "3", "status": "requested", "catalog_entry_id": "routed-but-open"}, + ], + ) + result = statehub_bridge.list_open_capability_requests() + assert {r["id"] for r in result} == {"1", "3"} + + +def test_list_open_capability_requests_none_when_unreachable(monkeypatch): + monkeypatch.setattr(statehub_bridge, "state_hub_reachable", lambda base_url=None: False) + assert statehub_bridge.list_open_capability_requests() is None diff --git a/tools/README.md b/tools/README.md index cd08000..0453b8c 100644 --- a/tools/README.md +++ b/tools/README.md @@ -51,6 +51,28 @@ reuse-surface overlaps reuse-surface overlaps --threshold 0.35 ``` +### plan-check + +Query-before-build check (REUSE-WP-0018): match a draft workplan or a free +intent against the federated capability index before starting new work. +Deterministic token matching against `registry/indexes/federated.yaml`; +advisory only — never blocks workplan creation. See `specs/PlanCheck.md`. + +```bash +reuse-surface plan-check workplans/XXX-WP-0042-something.md +reuse-surface plan-check --intent "parse invoices and file evidence" +reuse-surface plan-check --intent "..." --format json +reuse-surface plan-check --intent "..." --record-outcome reused +reuse-surface plan-check --intent "..." --file-request --requesting-domain infotech +``` + +`reuse|extend|new` verdict from `--reuse-threshold`/`--extend-threshold` +(defaults 0.45/0.22). `--record-outcome` appends to +`registry/telemetry/plan-check-events.jsonl` (schema shared with +REUSE-WP-0019's reuse telemetry). `--file-request` files a State Hub +capability request on a `new` verdict (requires the hub reachable at +`127.0.0.1:8000`; degrades gracefully offline). + ### catalog Generate human-readable catalog artifacts (UC-RS-018). @@ -110,8 +132,14 @@ Run the service locally: `REUSE_SURFACE_TOKEN=dev-token reuse-surface serve` reuse-surface report gaps reuse-surface report gaps --format json reuse-surface report gaps --roster registry/federation/local-repo-roster.yaml +reuse-surface report gaps --check-capability-requests ``` +`--check-capability-requests` (REUSE-WP-0018-T04) also lists open State Hub +capability requests with no matching federated capability; requires the hub +reachable at `127.0.0.1:8000` and degrades gracefully (prints "skipped") when +it isn't. Off by default so `report gaps` stays fast and offline-safe. + Workstation roster report: publish blockers, empty scaffolds, seed-ready repos, and local index owner stubs pending dedup. diff --git a/workplans/REUSE-WP-0018-plan-check-consumption-loop.md b/workplans/REUSE-WP-0018-plan-check-consumption-loop.md index da8a831..df6c52e 100644 --- a/workplans/REUSE-WP-0018-plan-check-consumption-loop.md +++ b/workplans/REUSE-WP-0018-plan-check-consumption-loop.md @@ -82,7 +82,7 @@ maturity vector, owning repo, and consumer guidance. ```task id: REUSE-WP-0018-T01 -status: todo +status: done priority: high state_hub_task_id: "c7b3407a-2b9d-46ba-ae9c-7ab12235f0ba" ``` @@ -102,17 +102,24 @@ Design doc `specs/PlanCheck.md`: ```task id: REUSE-WP-0018-T02 -status: todo +status: done priority: high state_hub_task_id: "24911d0e-41bc-4c8c-a39a-7dc8ec65d8c6" ``` -- `reuse_surface/plan_check.py` + CLI command per T01 spec, no-LLM path -- Reads `registry/indexes/federated.yaml` (fall back to local index; warn on - stale `updated`) -- Markdown report for TTY; `--format json` for agents -- Pytest: fixture index with known reuse/extend/new cases; workplan-file and - intent-text inputs +Implemented in `reuse_surface/plan_check.py` + `plan-check` CLI command: + +- Reuses `overlaps.py`'s `TOKEN_RE`/Jaccard approach rather than a second + scoring method (per spec §4) +- Reads `registry/indexes/federated.yaml`, falls back to the local index, + warns when the composed index is >14 days stale +- Markdown (TTY default) and `--format json` (validated against + `schemas/plan-check-result.schema.json`) +- 11 pytest cases: verdict thresholds, workplan-file parsing, intent-text + parsing, tie-window vector ranking, staleness warning, CLI wiring +- `--record-outcome` appends to `registry/telemetry/plan-check-events.jsonl` + per the shared `schemas/reuse-event.schema.json` (WP-0019-T04 will post the + same shape to the hub) ## Add llm-connect Semantic Rerank @@ -123,6 +130,11 @@ priority: medium state_hub_task_id: "9040505a-238e-4815-ae1a-8c5f8c12afa9" ``` +**Not started** — `llm-connect` is not running on this workstation +(same gap noted in REUSE-WP-0017-T04's drafting cohorts). Deterministic +`plan-check` (T02) is fully usable without it; this task adds the optional +rerank on top when a backend is available. + - Optional rerank/expansion stage via `LLM_CONNECT_URL` (reuse `llm_bridge.py`); schema-constrained JSON, graceful skip when unset - Confidence surfaced per match; deterministic matches always listed first @@ -132,20 +144,29 @@ state_hub_task_id: "9040505a-238e-4815-ae1a-8c5f8c12afa9" ```task id: REUSE-WP-0018-T04 -status: todo +status: done priority: medium state_hub_task_id: "8249dca7-18ca-4150-9671-ca87e6a5ad88" ``` -Two directions: +Implemented in `reuse_surface/statehub_bridge.py` against the live local +State Hub HTTP API (`http://127.0.0.1:8000`, confirmed reachable): -- **new → request:** on a `new` verdict, offer (`--file-request` / - interactive prompt) to create a State Hub capability request with the - intent text and searched terms -- **request → gap:** `report gaps` gains a section listing open State Hub - capability requests with no matching federated capability (HTTP against - `http://127.0.0.1:8000`, degrade gracefully offline) -- Matched requests can be marked resolved with a pointer to the capability id +- **new → request:** `plan-check --file-request` files a State Hub + capability request (`POST /capability-requests/`) on a `new` verdict +- **request → gap:** `report gaps --check-capability-requests` lists open + requests (`GET /capability-requests/`, filtered on `status` — a request + can carry a `catalog_entry_id` while still `requested`/routed, so status + is the correct open/closed signal, not `catalog_entry_id` presence). + Opt-in flag so `report gaps` stays fast and offline-safe by default +- Both degrade to `None`/graceful-skip when the hub is unreachable; neither + ever blocks the primary command +- List endpoint observed taking ~7s in production with only 5 rows; uses a + 20s timeout distinct from the 5s health-check timeout +- 5 pytest cases with the hub client fully mocked (never hits the network + in the default test run) +- **Not done:** marking matched requests resolved with a pointer to the + capability id — deferred, no current caller needs it yet ## Ecosystem Rollout: Session-Protocol Integration @@ -156,7 +177,12 @@ priority: high state_hub_task_id: "c232a669-b263-4978-a8b1-3721b8bfaa68" ``` -Blocked on WP-0017 coverage (index must be worth querying). +Blocked on WP-0017 coverage reaching the *published* federated index, not +just local drafts. WP-0017-T04 has drafted entries for all 61 roster repos +(coverage 61/61 locally), but those commits are still local-only in each +sibling repo pending WP-0017-T05's human review and push — `federated.yaml` +still only carries the original 24 entries. Re-check this gate once +WP-0017-T05 pushes and recomposes. - Draft the one-line convention for the shared session-protocol/workplan rules: *"Before creating a workplan, run `reuse-surface plan-check` on the @@ -171,25 +197,29 @@ Blocked on WP-0017 coverage (index must be worth querying). ```task id: REUSE-WP-0018-T06 -status: todo +status: done priority: low state_hub_task_id: "ff0a91eb-cc41-487b-b726-8c2f84860399" ``` -- `tools/README.md` command reference; `docs/RegistryFederation.md` consumer - section; `SCOPE.md` "What Is Possible Now" +- `tools/README.md` command reference for `plan-check` and + `report gaps --check-capability-requests`; `docs/RegistryFederation.md` + "Query before building" consumer section; `SCOPE.md` "What Is Possible Now" - CI: informational `plan-check --intent "smoke test" --format json` run -- Gap-analysis note: consumption loop status flips from missing to shipped + added to `.gitea/workflows/ci.yml` +- `docs/IntentScopeGapAnalysis.md`: priority 29 added, marked **Partial** + (T02/T04 shipped; T03/T05 remain open — not flipped fully to "shipped" + since the ecosystem rollout hasn't happened yet) --- ## Acceptance -- [ ] `plan-check` returns reuse/extend/new verdicts for workplan files and intent text, with and without llm-connect -- [ ] JSON output validates against the published schema -- [ ] `new` verdicts can file State Hub capability requests; `report gaps` lists unmatched open requests -- [ ] Session-protocol convention drafted and propagation agreed with the-custodian -- [ ] reuse-surface itself records `reuse_check` in new workplans (dogfood) +- [x] `plan-check` returns reuse/extend/new verdicts for workplan files and intent text (without llm-connect — T03 rerank not built, not available on this workstation) +- [x] JSON output validates against the published schema +- [x] `new` verdicts can file State Hub capability requests; `report gaps` lists unmatched open requests +- [ ] Session-protocol convention drafted and propagation agreed with the-custodian (blocked, see T05) +- [ ] reuse-surface itself records `reuse_check` in new workplans (dogfood) — deferred to T05 rollout ## Out of scope