Ticket graphs

Every ticket as the root of its provenance graph — goal, patch, approval, runs and (as the ticket-class build lands) mandate, manifestations, guarantees, journey, artifacts and children. Rendered through the <<<TicketGraphList>>> tag.

← all ticket-graphs

DONE#403 forge-refactor

Rewrite coupler:coupler/__main__.py as behavior-preserving, installable, control-config'd Python [forge-rw:eb9c79df006733b5]

goal

THIS UNIT SUPERSEDES #296 — it is the SAME module and the same work, re-filed to be SELF-CONTAINED. The superseded unit named its source only as a `git cat-file` command against the forge's bare repos, which the sandboxed pair_loop worker that executes these tickets cannot run, so it could not be done as filed (#346; #259 is the proof). A ticket body is permanent in SQL (tg_tickets_immutable_identity, migrations/0029_ticket_authorization .sql:111-117), so that body could not be repaired — this ticket carries the fix instead, and the original is halted by a stop ticket rather than erased. Do the work HERE. Rewrite ONE unique forge module as entirely new, behavior-preserving, installable, control-config'd Python, per the FORGE-wide refactor standard #200 (#std). This ticket covers ONE module — the one identified below — and nothing else. It was filed against the module's DEDUPLICATED identity, so if that module was copy-pasted across the forge, every copy is covered by this single ticket. MODULE KEY : forge-rw:eb9c79df006733b5 (the ticketer's idempotency marker — it lives in this ticket's TITLE. Do not edit the title, or the module will be re-filed as a duplicate.) IDENTITY : AST-NORMALIZED — codebean's `_ast_norm` (#183), i.e. ast.dump(ast.parse(ast.unparse(ast.parse(src)))). Copies of this module that differ only in comments, blank lines, quoting or spacing carry the SAME module-sha and are covered by THIS ticket. A differing docstring is real content, so a module whose docstring differs is a DIFFERENT module with its own ticket. SIZE : 9329 bytes EXTENSION : .py CANONICAL SOURCE (the location this ticket is named for): repo : coupler path : coupler/__main__.py ref : HEAD (as recorded when this module was first filed) blob : d2e63a01fb9d0a5b91c9f3125051c8e0f772a3ac (git blob sha1) REFERENCE ONLY — you do NOT need this command. This module's bytes are embedded COMPLETE in THE ORIGINAL SOURCE block below, and that is what the rewrite is based on. For a reader who does have forge access: git --git-dir=/Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes/coupler.git cat-file blob d2e63a01fb9d0a5b91c9f3125051c8e0f772a3ac THE SAME MODULE ALSO LIVES AT THESE FORGE TIPS — 1 copy/copies. They SHARE THIS ticket: this module is rewritten ONCE, and every location below is satisfied by that one rewrite. Do not file a ticket per copy. gitlab__testmonkeyalpha__coupler:coupler/__main__.py HISTORICAL OCCURRENCES of this exact blob — every OTHER DISTINCT {repo, path} the omni-git index (`occ`, joined on the git blob sha1) records this byte-identical file at, with the index's own `ts` for the first row of each. Any place this run enumerated LIVE is listed under the forge tips above, not here. Context for the rewrite, not extra work: testmonkeyalpha/coupler:coupler/__main__.py [occ.ts gitlab] THE ORIGINAL SOURCE, EMBEDDED — YOU NEED NO FORGE ACCESS TO DO THIS TICKET. The module's own bytes are reproduced below, inside this ticket. THEY are the authoritative copy and the behavior-preserving basis: rewrite from them. The `git --git-dir=... cat-file blob` command under CANONICAL SOURCE above is kept only as a REFERENCE for a reader who happens to have forge access — running it is NOT part of this ticket. (Why the bytes are here at all: the pair_loop worker that executes this ticket is sandboxed to the ticket's own congruency worktree and cannot read the forge's bare repos. Ticket #346 is the fix that put them on the ticket.) The block BEGINS at the line ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== and ENDS at the first line ===END SOURCE=== Everything strictly between those two lines is the module — 9329 byte(s), byte for byte, with no re-indentation, no re-wrapping and nothing elided. ===ORIGINAL SOURCE (verbatim, the behavior-preserving basis)=== #!/usr/bin/env python3 """ coupler — stable MCP entry point for Claude Desktop. Reads jazz build.json to find the active versioned env, then proxies all jazz MCP services through FastMCP. Claude Desktop always points here. Jazz installs come and go — this never changes. Environment: JAZZ_ROOT path to jazz checkout (default: ~/code/workspace/main) JAZZ_DEV path to jazz dev repo (default: ~/code/jazz) NOTES_DB_URL notes_db service URL (default: http://127.0.0.1:8765) JSON_CONTROLS_URL browser UI URL (default: http://127.0.0.1:8142) """ import json import os import sys from pathlib import Path import httpx from fastmcp import FastMCP, Client from fastmcp.client.transports import StdioTransport # ── Resolve jazz paths ──────────────────────────────────────────────────────── JAZZ_ROOT = Path(os.environ.get("JAZZ_ROOT", Path.home() / "code/workspace/main")) JAZZ_DEV = Path(os.environ.get("JAZZ_DEV", Path.home() / "code/jazz")) def _load_build() -> dict: p = JAZZ_ROOT / "conf/env/build.json" if p.exists(): return json.loads(p.read_text()) return {} def _notes_python() -> str: """Interpreter used to launch downstream MCP subprocesses. They run under the SAME venv as the coupler — whatever Python is running this. (Historically this hunted a conda env via build.json; conda is gone since old_jazz, so that path was dead.) Override with COUPLER_PYTHON if a downstream needs a different interpreter. """ return os.environ.get("COUPLER_PYTHON", sys.executable) # ── MCP server ──────────────────────────────────────────────────────────────── mcp = FastMCP("coupler") # ── Proxy jazz MCP services ─────────────────────────────────────────────────── def _jazz_client(module: str) -> Client: python = _notes_python() return Client(f"{python} -m {module}") @mcp.tool() async def notes_db_proxy(tool: str, args: dict = {}) -> str: """Forward a tool call to the notes_db_mcp service.""" async with _jazz_client("notes_db_mcp") as c: result = await c.call_tool(tool, args) return str(result) @mcp.tool() async def z3_proxy(tool: str, args: dict = {}) -> str: """Forward a tool call to the z3_mcp service.""" async with _jazz_client("z3_mcp") as c: result = await c.call_tool(tool, args) return str(result) # ── Mount jazz services as proper proxies (exposes their tools natively) ────── def _build_app(): python = _notes_python() build = _load_build() version = build.get("version", "unknown") print(f" coupler starting", file=sys.stderr) print(f" jazz version : {version}", file=sys.stderr) print(f" notes python : {python}", file=sys.stderr) app = FastMCP("coupler") # mount each jazz service — tools appear natively in Claude services = { "notes_db": "notes_db_mcp", "z3": "z3_mcp", } from fastmcp.server import create_proxy for name, module in services.items(): if Path(python).exists(): try: proxy = create_proxy( Client(StdioTransport(command=python, args=["-m", module])) ) app.mount(proxy, namespace=name) print(f" mounted : {name} ({module})", file=sys.stderr) except Exception as e: print(f" [warn] could not mount {name}: {e}", file=sys.stderr) # ── why5 (from jazz dev tree) ───────────────────────────────────────────── sys.path.insert(0, str(JAZZ_DEV / "jazz")) try: from agent.why5 import Why5, from_ci_failure, from_commit_failure _why5_sessions: dict = {} @app.tool() def why5_new(session_id: str, trigger: str = "") -> str: """Start a new 5-why root cause analysis session.""" _why5_sessions[session_id] = Why5(session_id=session_id, trigger=trigger) return f"why5 session '{session_id}' started" @app.tool() def why5_fact(session_id: str, name: str, value: bool) -> str: """Assert a known observable fact into a why5 session.""" _why5_sessions[session_id].fact(name, value) return f"fact '{name}' = {value}" @app.tool() def why5_link(session_id: str, label: str, cause: str, because: str) -> str: """Add a causal link: if `because` is true then `cause` must be true.""" _why5_sessions[session_id].link(label, cause=cause, because=because) return f"link [{label}] {because} → {cause}" @app.tool() def why5_check(session_id: str, save: bool = False) -> str: """Verify the causal chain with Z3. Optionally save to notes_db.""" w = _why5_sessions.get(session_id) if not w: return f"error: session '{session_id}' not found" result = w.check() if save: w.save() return json.dumps(result, indent=2) @app.tool() def why5_from_ci_failure(session_id: str, branch: str, sha: str, pytest_output: str, save: bool = False) -> str: """Build and verify a why5 chain from CI pytest output.""" w = from_ci_failure(session_id, branch=branch, pre_sha=sha, pytest_output=pytest_output) _why5_sessions[session_id] = w result = w.check() if save: w.save() return json.dumps(result, indent=2) print(f" mounted : why5", file=sys.stderr) except Exception as e: print(f" [warn] why5 not available: {e}", file=sys.stderr) # ── UI bridge (httpx, always direct) ───────────────────────────────────── UI_BASE = os.environ.get("JSON_CONTROLS_URL", "http://127.0.0.1:8142") UI_TIMEOUT = 5.0 def _ui_get(path, **params): r = httpx.get(f"{UI_BASE}{path}", params=params, timeout=UI_TIMEOUT) r.raise_for_status() return r.json() def _ui_post(path, body): r = httpx.post(f"{UI_BASE}{path}", json=body, timeout=UI_TIMEOUT) r.raise_for_status() return r.json() def _ui_put(path, body): r = httpx.put(f"{UI_BASE}{path}", json=body, timeout=UI_TIMEOUT) r.raise_for_status() return r.json() @app.tool() def ui_set_page(config: dict) -> str: """Push a page config to the browser UI.""" name = config.get("name") if not name: return "error: config must have a 'name' field" try: _ui_post(f"/page/{name}", config) return f"page '{name}' registered" except Exception as e: return f"error: {e}" @app.tool() def ui_get_state(page: str) -> str: """Get the current value of every control on a UI page.""" try: return json.dumps(_ui_get(f"/state/{page}"), indent=2) except Exception as e: return f"error: {e}" @app.tool() def ui_set_value(page: str, name: str, value: str) -> str: """Set a control value on a UI page.""" parsed: object = value if value.lower() == "true": parsed = True elif value.lower() == "false": parsed = False else: try: parsed = int(value) except ValueError: try: parsed = float(value) except ValueError: pass try: _ui_put(f"/state/{page}/{name}", {"value": parsed}) return f"set {page}.{name} = {parsed!r}" except Exception as e: return f"error: {e}" @app.tool() def ui_list_pages() -> str: """List all registered UI pages.""" try: pages = _ui_get("/pages") return json.dumps(pages, indent=2) if pages else "no pages registered" except Exception as e: return f"error: {e}" @app.tool() def ui_add_message(page: str, control: str, role: str, text: str) -> str: """Append a message to a chatbox control on a UI page.""" try: _ui_post(f"/message/{page}/{control}", {"role": role, "text": text}) return f"message added to {page}.{control}" except Exception as e: return f"error: {e}" @app.tool() def ui_get_log(limit: int = 20) -> str: """Get recent control change events from the browser.""" try: log = _ui_get("/log", limit=limit) return json.dumps(log, indent=2) if log else "no changes yet" except Exception as e: return f"error: {e}" print(f" mounted : ui (port 8142)", file=sys.stderr) return app if __name__ == "__main__": app = _build_app() app.run() ===END SOURCE=== THE CALLABLE SURFACE — every callable in the original, lowered with codebean's `lower_node` (#183). The rewrite must present THIS surface (same names, same parameters, same defaults) so callers of the original keep working: _load_build() -> dict _notes_python() -> str _jazz_client(module: str) -> Client async notes_db_proxy(tool: str, args: dict={}) -> str async z3_proxy(tool: str, args: dict={}) -> str _build_app() CAPTURED BEHAVIORAL EXEMPLARS: none. no function in this module is minable — criteria_miner mines only self-contained pure functions (it refuses anything that mentions a world-touching or non-deterministic name, plus methods and no-arg functions), and nothing here qualified This does NOT weaken the ticket's bar — it MOVES the work: you must write the equivalence exemplars by hand from the original's behavior before rewriting, and they are what settles this ticket. THE STANDARD — #200's four requirements, which this ticket exists to enforce. All four are the standard's own words; none is optional: 1. BEHAVIOR-PRESERVING, VERIFIED NOT ASSERTED — the deliverable is ENTIRELY NEW source code (rewritten, not copied). It must reproduce the ORIGINAL's behavior, and the proof is the exemplars above (captured via codebean lower_* #183 + criteria_miner IO-exemplars, exactly as #200 specifies). 2. A PYTHON MODULE — regardless of the source language. This module's source is `.py`; the rewrite is Python either way. 3. INSTALLABLE — a proper package: pyproject.toml, `pip install -e .` works, console entry points where applicable. 4. CONTROL-CONFIG'D — NO hardcoded paths or params. A JSON/registry config object drives it (the install.json / registry.json idiom of this repo), and per note-for-claude a tool that cannot see its config THROWS rather than guessing a default. ACCEPTANCE (#200's, per rewrite ticket) — this ticket settles when ALL of: a. the new source reproduces every captured exemplar above, plus the edge cases you add for what sampling cannot reach (paste the runs as evidence); b. it presents the callable surface above, so existing callers keep working; c. `pip install -e .` installs it clean from its own pyproject.toml; d. it runs off a config object with ZERO hardcoded settings, and throws without one. Kind is `build` (a pair: coder + adversarial tester). This ticket hangs off #200 by a `spawned` edge, so a stop ticket aimed at #200 brakes every rewrite in this programme. Filed mechanically by checkouts/current/congruency/tools/forge_refactor_ticketer.py under ticket #201, from the forge bare repos at /Users/stevenpeterson/code/installed/jazz-project/config/forge/remotes. Its original source is embedded above (#346), so this ticket is complete on its own.

patch

none

approval

unapproved

runs

no runs recorded

mandate (clauses)

not yet recorded — lands with the ticket-class build

manifestations

not yet recorded — lands with the ticket-class build

guarantees

not yet recorded — lands with the ticket-class build

journey (blunders & successes)

not yet recorded — lands with the ticket-class build

artifacts (forge)

not yet recorded — lands with the ticket-class build

source (forge tree)

browse congruency source (forge-parked tree)

children

#296 Rewrite coupler:coupler/__main__.py as behavior-preserving, installable, control-config'd Python [forge-rw:eb9c79df006733b5] (supersedes · REQUESTED)