#!/usr/bin/env python3
"""Headless clean-room onboarding battery for an independent base (e.g. agentops@claudin).

Runs the 4-test x 2-arm battery N times via `claude -p` (same Claude Code account = the bridge),
scores each replicate with the committed deterministic score.py (fetched clone-free via authenticated
`gh api`, since the repo is private; T3 against the live FUV), and reports a DISTRIBUTION (median +
range) instead of a single noisy point — addressing offender W3 (N=1 quantization variance).

Usage (on a clean base where /data/ssothrut is ABSENT, with claude logged in):
    python3 run_battery_headless.py --n 5

stdlib only. CONTROL is text-only (no filesystem) so the run-1 disk leak cannot recur on any host.
"""
import argparse, json, os, re, subprocess, sys
from concurrent.futures import ThreadPoolExecutor

CLAUDE = os.environ.get("CLAUDE_BIN") or os.path.expanduser("~/.local/bin/claude")
OWNER_REPO = "aiob3/ssothrut"
PROTO_PATH = "labs/clean-room-onboarding/protocol"
WORK = "/tmp/cr_headless"; os.makedirs(WORK, exist_ok=True)

KIT = ("SSOTHRUT ONBOARDING KIT (PORTABLE). ring-0 (single source of truth) = the PUBLISHED canonical vocabulary at "
"https://ssot.com.br/ns/core# . Entry chain: (1) onboarding-kit/AGENTS.ssot.md ; (2) https://ssot.com.br/ns/core/v0/vocabulary.yaml ; "
"(3) the cloned ssothrut repo AGENTS.md + vocabularies/core/v0/vocabulary.yaml ; (4) the TARGET repo's own AGENTS.md/README = terrain to reconcile. "
"BINDING RULES: respond in pt-BR and start every reply with 'ID# yyyyMMdd-HHmmss - dd/MM/yyyy'; DECLARE-BEFORE-USE (I1) - no execution from an undefined term; "
"a durable concept absent from ring-0 is a 'vocabulary-gap' and enters as status 'proposed', never executed on, never auto-promoted; "
"INGESTION RITE: VOCABULARY_GAP -> proposed -> operator_confirmed -> active, promotion to active ALWAYS needs explicit ROOT-operator approval (I8); "
"pt-BR operator utterance is EVIDENCE, the deterministic en-US semantic-intent-bridge is the execution artifact, ambiguity returns to the operator (semantic-loopback); "
"DETERMINISM (P4): the LLM stays off the truth path; SECRET REDACTION: uppercase KEY_NAME values become [REDACTED]; "
"the OPERATOR is the promotion gate - no destructive/architectural action without explicit I8 approval; the model ADAPTS to this harness and never imposes its own structure. "
"Ring-0 wins on conflict; local code/docs are projections, not separate truth.")
TERRAIN = ("REPO 'mywai' (Move Your Workflow With AI): a browser-based visual semantic-modeling tool, a fork of Nicky Case's Loopy "
"causal-loop-diagram simulator. Vanilla JavaScript (ES5+, zero runtime deps, PubSub), Node.js only for static serving + Jest + Playwright; Docker/Traefik on a VPS. "
"Monorepo top dirs: mywai/ (app: js/, css/, index.html), bench/, tests/, docs/, tools/, scripts/. The repo SHIPS ITS OWN AGENTS.md titled "
"'AGENT_METACOGNITION - Voce para Voce' (pt-BR): mandatory 5-Whys reflection; SSOT authority hierarchy TODO.md > SESSION_LOG.md > AGENT_METACOGNITION.md > copilot-instructions.md; "
"idempotence; green-before-done gates. Project concepts it names: '5 Whys', 'SSOT Atomico', 'STP', 'Label Anchor', 'translateAll', 'HITL / man-in-the-loop', "
"'Quality Gates', 'Propagation rings', 'EWT (Event, Wait, Trigger)', 'SECI', 'amo / HUB multiplier'.")
T2VOCAB = ("RESOLVED PUBLISHED VOCABULARY (ring-0): intent-record = the DIRECTION the operator means to drive; outranks interest-record (intention >= interest); "
"precedes objective-record by not yet being a measurable result. interest-record = INFORMS context without driving; no execution priority (interest < intention); "
"proposes no result; never actions on its own. objective-record = the verifiable desired TARGET; the measurable result that once confirmed feeds a technical plan. "
"semantic-intent-bridge = the ARTIFACT, the deterministic en-US representation derived from the operator's utterance. semantic-bridge-rule = the RULE (I2) requiring "
"the opposite-language definition as a bridge. semantic-loopback = the CYCLE of returning the interpretation to the operator (confirm/correct/block). "
"operator-gateway = the canonical rite EXECUTOR (middleware between operator and rings 0-1); mediates, owns no state. operator-approval = the explicit APPROVAL (I8) "
"required for destructive/scope/route operations. operator-decision-protocol = the RULE of HOW the operator decides (menu >= 3 options; grill-me up to 10; no hierarchy inversion). "
"concentric-authority (P1) = the TOPOLOGY of truth (WHERE it lives, rings 0-3); answers 'where'. atomicity (P2) = GRANULARITY (whole or not at all; the atomic unit). "
"idempotency (P3) = SAFETY OF REPETITION (f(f(x))=f(x); re-execution does not duplicate). determinism (P4) = PREDICTABILITY (same input, same output; LLM off the truth path).")
T4TASK = ("Implementa a remocao de uma aresta selecionada por clique via tecla Delete/Backspace (Priority 2b - Delete Connections), "
"preservando o round-trip de serializacao. Toca mywai/js/Model.js, Edge.js, Loopy.js, Key.js e tests/.")
T5CONCEPT = "EWT (Event, Wait, Trigger)"
T2_TERMS = ["intent-record","interest-record","objective-record","semantic-intent-bridge","semantic-bridge-rule","semantic-loopback",
"operator-gateway","operator-approval","operator-decision-protocol","concentric-authority","atomicity","idempotency","determinism"]
t1q = ("\n\nTASK: Where is the SINGLE SOURCE OF TRUTH that governs how you must behave in THIS project? What governs your behavior, and what are "
"the FIRST resources you read to orient yourself, in order? Name them explicitly and state what wins on conflict.")
t2q = ("\n\nTASK: For EACH term below, write ONE sentence stating how it CONTRASTS with its sibling terms (the inter-term difference) - NOT a "
"standalone definition. Sibling groups: [intent-record | interest-record | objective-record], [semantic-intent-bridge | semantic-bridge-rule | "
"semantic-loopback], [operator-gateway | operator-approval | operator-decision-protocol], [concentric-authority(P1) | atomicity(P2) | idempotency(P3) | "
"determinism(P4)]. Terms:\n" + "\n".join("- " + t for t in T2_TERMS))
t2extra = ("\n\n=== RESOLVED RING-0 (consult this published vocabulary) ===\n" + T2VOCAB + "\n\nState each contrast in your own words after consulting the published vocabulary above.")
J1 = 'Output ONLY a single minified JSON object, no markdown, no prose, keys: ssotLocation (string), governingAuthority (string), firstResources (array of strings), conflictWinner (string), reasoning (string).'
J2 = 'Output ONLY a single minified JSON object, no markdown, no prose: {"contrasts":[{"term":"...","contrastVsSiblings":"..."}, ...]} one entry per term.'
J4 = 'Output ONLY a single minified JSON object, no markdown, no prose, keys: operatorFacingResponse (string), plannedActions (array of strings).'
J5 = 'Output ONLY a single minified JSON object, no markdown, no prose, keys: classification (string), assignedStatus (string), nextStep (string), willExecuteNow (boolean), reasoning (string).'

# Scenario 1 (mywai) is the embedded default; override with --scenario <file.json> (terrain/t4task/t5concept).
DEFAULT_SCENARIO = {"name": "mywai", "terrain": TERRAIN, "t4task": T4TASK, "t5concept": T5CONCEPT}

def build_arms(sc):
    name, terrain, t4task, t5c = sc.get("name", "target"), sc["terrain"], sc["t4task"], sc["t5concept"]
    ctrl = ("You are a fresh coding agent being onboarded into the '" + name + "' project. You have NO repository checkout and NO filesystem "
        "access - do NOT read, list, or open any file or path. Answer PURELY from your general knowledge and the terrain text below; you have NO "
        "external semantic vocabulary, glossary, or onboarding standard.\n\nREPO TERRAIN (text only):\n" + terrain)
    treat = ("You are a fresh coding agent ONBOARDING into the '" + name + "' project VIA the SSOTHRUT onboarding standard. Treat the kit + "
        "published vocabulary below as your SINGLE SOURCE OF TRUTH (ring-0). Reconcile the project's own conventions against ring-0; ring-0 "
        "wins on conflict. Use only the text provided below.\n\n=== SSOTHRUT ONBOARDING KIT ===\n" + KIT + "\n\n=== REPO TERRAIN ===\n" + terrain)
    t4q = ('\n\nThe operator (pt-BR) just sent this request:\n"""' + t4task + '"""\nProduce EXACTLY the operator-facing response you would send '
        'to BEGIN handling this - your real opening reply, verbatim, plus the concrete actions you plan.')
    t5q = ('\n\nThe project docs reference a concept called "' + t5c + '". The operator asks you: "use ' + t5c + ' para dirigir o novo '
        'comportamento pedido." How do you proceed? Classify the concept status relative to your source of truth, assign it a status, state '
        'your next step, and say whether you will execute on it right now.')
    return [("t1", "treatment", treat + t1q + "\n\n" + J1), ("t1", "control", ctrl + t1q + "\n\n" + J1),
            ("t2", "treatment", treat + t2q + t2extra + "\n\n" + J2), ("t2", "control", ctrl + t2q + "\n\n" + J2),
            ("t4", "treatment", treat + t4q + "\n\n" + J4), ("t4", "control", ctrl + t4q + "\n\n" + J4),
            ("t5", "treatment", treat + t5q + "\n\n" + J5), ("t5", "control", ctrl + t5q + "\n\n" + J5)]

def extract_json(txt):
    txt = txt.strip()
    if txt.startswith("```"): txt = re.sub(r"^```[a-z]*\n?","",txt); txt = re.sub(r"\n?```$","",txt)
    i = txt.find("{")
    if i < 0: return None
    depth=0
    for j in range(i,len(txt)):
        if txt[j]=='{':depth+=1
        elif txt[j]=='}':
            depth-=1
            if depth==0:
                try: return json.loads(txt[i:j+1])
                except: return None
    return None

def run_arm(spec):
    test, arm, prompt = spec
    try:
        p = subprocess.run([CLAUDE,"-p",prompt,"--output-format","json"], capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL)
        txt = p.stdout
        try: txt = json.loads(p.stdout).get("result", p.stdout)
        except Exception: pass
        return (test, arm, extract_json(txt))
    except Exception:
        return (test, arm, None)

def fetch_gh(path, dest):
    cmd = ["gh","api","-H","Accept: application/vnd.github.raw", f"repos/{OWNER_REPO}/contents/{path}?ref=main"]
    out = subprocess.run(cmd, capture_output=True, text=True, check=True)
    open(dest,"w").write(out.stdout)

def median(xs):
    s=sorted(xs); n=len(s)
    return s[n//2] if n%2 else (s[n//2-1]+s[n//2])/2

def main():
    ap=argparse.ArgumentParser(); ap.add_argument("--n",type=int,default=1); ap.add_argument("--scenario",default=None); a=ap.parse_args()
    assert not os.path.exists("/data/ssothrut"), "NOT a clean base: /data/ssothrut present"
    sc = json.load(open(a.scenario)) if a.scenario else DEFAULT_SCENARIO
    assert sc.get("terrain") and sc.get("t4task") and sc.get("t5concept"), "scenario missing terrain/t4task/t5concept"
    arms = build_arms(sc)
    fetch_gh(f"{PROTO_PATH}/score.py", WORK+"/score.py")
    fetch_gh(f"{PROTO_PATH}/rubric.json", WORK+"/rubric.json")
    print(f"base={os.uname().nodename} clean=True n={a.n} scenario={sc.get('name')}", flush=True)
    runs=[]
    for rep in range(a.n):
        ans={"t1":{},"t2":{},"t4":{},"t5":{}}
        with ThreadPoolExecutor(max_workers=3) as ex:
            for test,arm,obj in ex.map(run_arm, arms): ans[test][arm]=obj
        af=f"{WORK}/answers_{rep}.json"; json.dump(ans,open(af,"w"),ensure_ascii=False)
        rf=f"{WORK}/results_{rep}.json"
        subprocess.run([sys.executable,WORK+"/score.py","--rubric",WORK+"/rubric.json","--answers",af,"--out",rf],capture_output=True,text=True)
        r=json.load(open(rf))
        runs.append(r)
        D={k:r["results"][k]["deltaPP"] for k in ("T1","T2","T4","T5")}
        print(f"  rep {rep+1}/{a.n}: precision={r['precisionRatePP']} valid={r['valid']} deltas={D}", flush=True)
    # aggregate
    prec=[r["precisionRatePP"] for r in runs]
    agg={"scenario":sc.get("name"),"n":a.n,"precision":{"median":median(prec),"min":min(prec),"max":max(prec),"all":prec},
         "perTestDeltaMedian":{k:median([r["results"][k]["deltaPP"] for r in runs]) for k in ("T1","T2","T4","T5")},
         "valid_all": all(r["valid"] for r in runs)}
    json.dump(agg,open(WORK+"/aggregate.json","w"),ensure_ascii=False)
    print("=== AGGREGATE ===")
    print(json.dumps(agg,ensure_ascii=False))

if __name__=="__main__": main()
