|
| 1 | +"""Server-owned canonical handoff contract (``jcodemunch.handoff/v1``, #374). |
| 2 | +
|
| 3 | +A multi-step repository audit ends with one authoritative Markdown result. |
| 4 | +The assistant authors the analysis (the server never writes conclusions); |
| 5 | +this module owns everything downstream of authorship: deterministic assembly, |
| 6 | +evidence attestation, persistence, identity, hashing, and immutable serving |
| 7 | +via the ``munch://handoff/<id>`` resource. |
| 8 | +
|
| 9 | +The differentiator no client Stop hook can provide: ``evidence_refs`` are |
| 10 | +validated against the session's actual retrieval record (the yield tracker's |
| 11 | +served-symbol ids) at finalization time, so a finalized handoff attests that |
| 12 | +every evidence reference it cites corresponds to something actually served |
| 13 | +by this server in this session. Unknown refs fail closed. |
| 14 | +
|
| 15 | +Charter constraints (accepted scope, issue #374): |
| 16 | +- Deterministic: same repo/task/profile/sections/evidence/appendices -> |
| 17 | + byte-identical body, same id, same sha256. |
| 18 | +- Each appendix exactly once; duplicate names rejected. |
| 19 | +- No arbitrary character limit. |
| 20 | +- Session-scoped (process == session, the _steer_state precedent), local-first, |
| 21 | + in-memory only — never writes to the user's repository or index store. |
| 22 | +- ``canonical: true`` in the receipt is advisory metadata for direct-render |
| 23 | + clients; it forces nothing. |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import hashlib |
| 29 | +import threading |
| 30 | +from typing import Iterable, Optional |
| 31 | + |
| 32 | +HANDOFF_SCHEMA = "jcodemunch.handoff/v1" |
| 33 | +HANDOFF_URI_PREFIX = "munch://handoff/" |
| 34 | +HANDOFF_CONTENT_TYPE = "text/markdown" |
| 35 | + |
| 36 | +# Session-scoped store: handoff_id -> {"body": str, "receipt": dict}. |
| 37 | +# Process lifetime == MCP session lifetime (same precedent as server._steer_state); |
| 38 | +# process exit is the cleanup. |
| 39 | +_lock = threading.Lock() |
| 40 | +_handoffs: dict[str, dict] = {} |
| 41 | + |
| 42 | + |
| 43 | +def _norm_file(path: str) -> str: |
| 44 | + return path.replace("\\", "/").lstrip("./") |
| 45 | + |
| 46 | + |
| 47 | +def _validate_evidence(refs, served_ids: Iterable[str]): |
| 48 | + """Attest each ref against the session retrieval record. |
| 49 | +
|
| 50 | + A ref is attested when it is exactly a served symbol id, or the file |
| 51 | + component (before ``::``) of a served id — a file-level citation of |
| 52 | + retrieved code. Returns (ordered_unique_refs, unknown_refs). |
| 53 | + """ |
| 54 | + served = set(served_ids or ()) |
| 55 | + served_files = {_norm_file(sid.split("::", 1)[0]) for sid in served} |
| 56 | + seen: set[str] = set() |
| 57 | + ordered: list[str] = [] |
| 58 | + unknown: list[str] = [] |
| 59 | + for ref in refs: |
| 60 | + if not isinstance(ref, str) or not ref.strip(): |
| 61 | + unknown.append(repr(ref)) |
| 62 | + continue |
| 63 | + ref = ref.strip() |
| 64 | + if ref in seen: |
| 65 | + continue |
| 66 | + seen.add(ref) |
| 67 | + ordered.append(ref) |
| 68 | + if ref not in served and _norm_file(ref) not in served_files: |
| 69 | + unknown.append(ref) |
| 70 | + return ordered, unknown |
| 71 | + |
| 72 | + |
| 73 | +def _validate_sections(sections): |
| 74 | + if not isinstance(sections, list) or not sections: |
| 75 | + return None, "sections must be a non-empty list of {heading, content} objects" |
| 76 | + out = [] |
| 77 | + for i, sec in enumerate(sections): |
| 78 | + if not isinstance(sec, dict): |
| 79 | + return None, f"sections[{i}] must be an object with 'heading' and 'content'" |
| 80 | + heading = sec.get("heading") |
| 81 | + content = sec.get("content") |
| 82 | + if not isinstance(heading, str) or not heading.strip(): |
| 83 | + return None, f"sections[{i}].heading must be a non-empty string" |
| 84 | + if not isinstance(content, str) or not content.strip(): |
| 85 | + return None, f"sections[{i}].content must be a non-empty string" |
| 86 | + out.append((heading.strip(), content.rstrip())) |
| 87 | + return out, None |
| 88 | + |
| 89 | + |
| 90 | +def _validate_appendices(appendices): |
| 91 | + if appendices is None: |
| 92 | + return [], None |
| 93 | + if not isinstance(appendices, list): |
| 94 | + return None, "appendices must be a list of {name, content} objects" |
| 95 | + out = [] |
| 96 | + names: set[str] = set() |
| 97 | + for i, app in enumerate(appendices): |
| 98 | + if not isinstance(app, dict): |
| 99 | + return None, f"appendices[{i}] must be an object with 'name' and 'content'" |
| 100 | + name = app.get("name") |
| 101 | + content = app.get("content") |
| 102 | + if not isinstance(name, str) or not name.strip(): |
| 103 | + return None, f"appendices[{i}].name must be a non-empty string" |
| 104 | + if not isinstance(content, str) or not content.strip(): |
| 105 | + return None, f"appendices[{i}].content must be a non-empty string" |
| 106 | + name = name.strip() |
| 107 | + if name in names: |
| 108 | + return None, f"duplicate appendix name: {name!r} (each appendix appears exactly once)" |
| 109 | + names.add(name) |
| 110 | + ctype = app.get("content_type") or "text/markdown" |
| 111 | + out.append((name, str(ctype), content.rstrip())) |
| 112 | + return out, None |
| 113 | + |
| 114 | + |
| 115 | +def render_handoff(repo: str, task: str, profile: str, sections, evidence_refs, appendices) -> str: |
| 116 | + """Deterministic canonical Markdown. No timestamps, no randomness.""" |
| 117 | + lines = [ |
| 118 | + f"# Handoff: {task}", |
| 119 | + "", |
| 120 | + f"- Schema: {HANDOFF_SCHEMA}", |
| 121 | + f"- Repo: {repo}", |
| 122 | + f"- Profile: {profile}", |
| 123 | + "", |
| 124 | + ] |
| 125 | + for heading, content in sections: |
| 126 | + lines += [f"## {heading}", "", content, ""] |
| 127 | + lines += [ |
| 128 | + "## Evidence", |
| 129 | + "", |
| 130 | + "Every reference below was validated against this session's retrieval", |
| 131 | + "record at finalization time (server-attested).", |
| 132 | + "", |
| 133 | + ] |
| 134 | + lines += [f"- `{ref}`" for ref in evidence_refs] |
| 135 | + lines.append("") |
| 136 | + for name, ctype, content in appendices: |
| 137 | + lines += [f"## Appendix: {name}", "", f"_Content type: {ctype}_", "", content, ""] |
| 138 | + return "\n".join(lines).rstrip() + "\n" |
| 139 | + |
| 140 | + |
| 141 | +def finalize_handoff( |
| 142 | + *, |
| 143 | + repo, |
| 144 | + task, |
| 145 | + sections, |
| 146 | + evidence_refs, |
| 147 | + profile: str = "general", |
| 148 | + appendices=None, |
| 149 | + served_ids: Optional[Iterable[str]] = None, |
| 150 | +) -> dict: |
| 151 | + """Assemble, attest, persist, and return the compact receipt. |
| 152 | +
|
| 153 | + Any validation failure returns ``{"error": ...}`` — the server dispatcher |
| 154 | + maps in-band error dicts to ``CallToolResult(isError=True)`` (v1.108.74 |
| 155 | + contract). The server never authors content: sections/appendices arrive |
| 156 | + verbatim from the caller; only assembly and attestation happen here. |
| 157 | + """ |
| 158 | + if not isinstance(repo, str) or not repo.strip(): |
| 159 | + return {"error": "repo must be a non-empty string"} |
| 160 | + if not isinstance(task, str) or not task.strip(): |
| 161 | + return {"error": "task must be a non-empty string"} |
| 162 | + if not isinstance(profile, str) or not profile.strip(): |
| 163 | + return {"error": "profile must be a non-empty string"} |
| 164 | + sec, err = _validate_sections(sections) |
| 165 | + if err: |
| 166 | + return {"error": err} |
| 167 | + apps, err = _validate_appendices(appendices) |
| 168 | + if err: |
| 169 | + return {"error": err} |
| 170 | + if not isinstance(evidence_refs, list) or not evidence_refs: |
| 171 | + return { |
| 172 | + "error": ( |
| 173 | + "evidence_refs must be a non-empty list of session retrieval " |
| 174 | + "references (symbol ids or file paths served this session by " |
| 175 | + "search_symbols / get_ranked_context)" |
| 176 | + ) |
| 177 | + } |
| 178 | + refs, unknown = _validate_evidence(evidence_refs, served_ids or ()) |
| 179 | + if unknown: |
| 180 | + return { |
| 181 | + "error": ( |
| 182 | + "evidence attestation failed: the following refs do not " |
| 183 | + "correspond to anything retrieved in this session" |
| 184 | + ), |
| 185 | + "unknown_refs": unknown, |
| 186 | + "hint": ( |
| 187 | + "Evidence refs must be symbol ids (or their file paths) that " |
| 188 | + "this session actually served via search_symbols or " |
| 189 | + "get_ranked_context. Retrieve the evidence first, then finalize." |
| 190 | + ), |
| 191 | + } |
| 192 | + |
| 193 | + body = render_handoff(repo.strip(), task.strip(), profile.strip(), sec, refs, apps) |
| 194 | + raw = body.encode("utf-8") |
| 195 | + sha256 = hashlib.sha256(raw).hexdigest() |
| 196 | + handoff_id = sha256[:16] |
| 197 | + receipt = { |
| 198 | + "schema": HANDOFF_SCHEMA, |
| 199 | + "handoff_id": handoff_id, |
| 200 | + "repo": repo.strip(), |
| 201 | + "profile": profile.strip(), |
| 202 | + "content_type": HANDOFF_CONTENT_TYPE, |
| 203 | + "resource_uri": f"{HANDOFF_URI_PREFIX}{handoff_id}", |
| 204 | + "sha256": sha256, |
| 205 | + "length": len(raw), |
| 206 | + "canonical": True, |
| 207 | + "evidence_attested": True, |
| 208 | + "evidence_count": len(refs), |
| 209 | + "appendices": [name for name, _, _ in apps], |
| 210 | + } |
| 211 | + with _lock: |
| 212 | + _handoffs[handoff_id] = {"body": body, "receipt": receipt} |
| 213 | + return dict(receipt) |
| 214 | + |
| 215 | + |
| 216 | +def get_handoff(handoff_id: str) -> Optional[dict]: |
| 217 | + """Return {"body", "receipt"} for a stored handoff, or None.""" |
| 218 | + with _lock: |
| 219 | + rec = _handoffs.get(handoff_id) |
| 220 | + return {"body": rec["body"], "receipt": dict(rec["receipt"])} if rec else None |
| 221 | + |
| 222 | + |
| 223 | +def handoff_for_uri(uri: str) -> Optional[dict]: |
| 224 | + """Resolve a munch://handoff/<id> URI to its stored record, or None.""" |
| 225 | + s = str(uri) |
| 226 | + if not s.startswith(HANDOFF_URI_PREFIX): |
| 227 | + return None |
| 228 | + return get_handoff(s[len(HANDOFF_URI_PREFIX):]) |
| 229 | + |
| 230 | + |
| 231 | +def list_handoff_resources() -> list[dict]: |
| 232 | + """Rows for list_resources(): one per finalized handoff this session.""" |
| 233 | + with _lock: |
| 234 | + return [ |
| 235 | + { |
| 236 | + "uri": rec["receipt"]["resource_uri"], |
| 237 | + "name": f"handoff-{hid}", |
| 238 | + "description": ( |
| 239 | + f"Canonical handoff for {rec['receipt']['repo']} " |
| 240 | + f"({rec['receipt']['profile']}); immutable, " |
| 241 | + f"sha256 {rec['receipt']['sha256'][:12]}…" |
| 242 | + ), |
| 243 | + } |
| 244 | + for hid, rec in _handoffs.items() |
| 245 | + ] |
| 246 | + |
| 247 | + |
| 248 | +def clear_handoffs() -> None: |
| 249 | + """Test hook: drop all session handoffs.""" |
| 250 | + with _lock: |
| 251 | + _handoffs.clear() |
0 commit comments