#!/usr/bin/env python3
"""Deterministic Missive proof board renderer. See board-status.schema.json and report.
No account calls, polling, framework, or deployment. Writes verification.html atomically.
"""
BASE_CSS = '\n    :root {\n      --ink: #162033;\n      --muted: #667085;\n      --line: #d8dee9;\n      --paper: #ffffff;\n      --canvas: #eef2f7;\n      --blue: #135dcc;\n      --blue-soft: #eaf2ff;\n      --green: #087443;\n      --green-soft: #e8f7ef;\n      --amber: #996300;\n      --amber-soft: #fff5d9;\n      --red: #b42318;\n      --red-soft: #fff0ee;\n      --shadow-1: 0 5px 0 rgba(20, 32, 52, .08), 0 18px 42px rgba(20, 32, 52, .16);\n      --shadow-2: 0 8px 0 rgba(20, 32, 52, .12), 0 24px 56px rgba(20, 32, 52, .21);\n    }\n    * { box-sizing: border-box; }\n    body { margin: 0; background: var(--canvas); color: var(--ink); font: 15px/1.5 Inter, system-ui, sans-serif; }\n    main { width: min(1060px, calc(100% - 28px)); margin: 24px auto 64px; }\n    h1, h2, h3, h4 { font-family: "Space Grotesk", Inter, sans-serif; letter-spacing: -.025em; }\n    h1 { margin: 0; font-size: clamp(29px, 6vw, 48px); line-height: 1.02; }\n    h2 { margin: 0 0 12px; font-size: 21px; }\n    h3 { margin: 0; font-size: 17px; }\n    h4 { margin: 0 0 7px; font-size: 11px; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); }\n    p { margin: 0; }\n    .surface { background: var(--paper); border: 1px solid var(--line); border-radius: 3px; box-shadow: var(--shadow-1); }\n    .hero { padding: 25px; border-radius: 3px; box-shadow: var(--shadow-2); }\n    .eyebrow { margin-bottom: 8px; color: var(--blue); font: 700 11px/1.2 "Space Grotesk", sans-serif; letter-spacing: .13em; text-transform: uppercase; }\n    .lede { max-width: 740px; margin-top: 12px; color: var(--muted); font-size: 16px; }\n    .row { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; }\n    .chips { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 18px; }\n    .chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 3px; font-size: 11px; font-weight: 700; }\n    .chip.status { color: var(--amber); background: var(--amber-soft); border: 1px solid #f0d48b; }\n    .chip.live { color: var(--blue); background: var(--blue-soft); border: 1px solid #c5dcff; }\n    .chip.meta { color: var(--muted); background: #f3f5f8; border: 1px solid #e4e8ee; }\n    .layout { display: grid; grid-template-columns: 1.12fr .88fr; gap: 14px; margin-top: 14px; }\n    .panel { padding: 18px; }\n    .goal { border-left: 5px solid var(--blue); }\n    .goal strong { color: var(--blue); }\n    .facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin-top: 15px; }\n    .fact { padding: 11px; border: 1px solid var(--line); border-radius: 3px; background: #fbfcfe; }\n    .fact strong { display: block; font: 700 22px/1 "Space Grotesk", sans-serif; }\n    .fact span { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; }\n    .section { margin-top: 14px; }\n    .section-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }\n    .section-title small { color: var(--muted); font-size: 11px; }\n    .stack { display: grid; gap: 10px; }\n    .message, .work-card { padding: 15px; }\n    .message { border-left: 4px solid var(--blue); }\n    .message.parent { border-left-color: #7c3aed; }\n    .message.child { border-left-color: var(--green); }\n    .message.blocked { border-left-color: var(--red); background: var(--red-soft); }\n    .message-head { display: flex; justify-content: space-between; gap: 12px; color: var(--muted); font-size: 11px; }\n    .message-head strong { color: var(--ink); font-size: 12px; }\n    .message p { margin-top: 8px; }\n    .work-card { border: 1px solid var(--line); border-radius: 3px; background: var(--paper); box-shadow: 0 4px 0 rgba(20, 32, 52, .08), 0 14px 32px rgba(20, 32, 52, .12); }\n    .work-card.done { border-color: #b7e2c8; background: linear-gradient(135deg, #fff 0%, #f4fcf7 100%); }\n    .work-card.blocked { border-color: #f0b9b2; background: linear-gradient(135deg, #fff 0%, #fff6f4 100%); }\n    .card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }\n    .state { white-space: nowrap; padding: 4px 8px; border-radius: 3px; font-size: 10px; font-weight: 800; }\n    .state.next { color: var(--amber); background: var(--amber-soft); }\n    .state.active { color: var(--blue); background: var(--blue-soft); }\n    .state.done { color: var(--green); background: var(--green-soft); }\n    .state.blocked { color: var(--red); background: var(--red-soft); }\n    .card-copy { margin-top: 6px; color: var(--muted); font-size: 13px; }\n    details { margin-top: 11px; border-top: 1px solid var(--line); padding-top: 10px; }\n    summary { cursor: pointer; color: var(--blue); font-size: 12px; font-weight: 700; }\n    .accordion { padding: 0; overflow: hidden; }\n    .accordion > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 14px 16px; color: var(--ink); font: 700 16px/1.2 "Space Grotesk", sans-serif; list-style: none; }\n    .accordion > summary small { color: var(--muted); font: 500 11px/1.2 Inter, sans-serif; }\n    .accordion > summary::-webkit-details-marker { display: none; }\n    .accordion > summary::after { content: "+"; color: var(--blue); font: 700 18px/1 "Space Grotesk", sans-serif; }\n    .accordion[open] > summary::after { content: "−"; }\n    .accordion > .accordion-body { padding: 0 16px 16px; }\n    .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }\n    .detail { padding: 10px; border-radius: 3px; background: #f8fafc; }\n    ul { margin: 0; padding-left: 18px; }\n    li { margin: 4px 0; }\n    .check { accent-color: var(--green); vertical-align: -2px; }\n    .proof { margin-top: 10px; padding: 9px 10px; border-left: 3px solid var(--blue); background: var(--blue-soft); color: var(--blue); font-size: 12px; }\n    .flow-shell { padding: 7px 4px 12px; }\n    .flow { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); gap: 21px; margin-top: 4px; }\n    .flow-node { position: relative; min-height: 142px; padding: 13px 14px 14px 16px; border: 1px solid var(--line); border-radius: 3px; background: #fbfcfe; box-shadow: 0 5px 0 rgba(20, 32, 52, .08), 0 16px 32px rgba(20, 32, 52, .13); }\n    .flow-node + .flow-node::before { content: "→"; position: absolute; top: 50%; left: -18px; transform: translateY(-50%); color: var(--blue); font: 700 20px/1 "Space Grotesk", sans-serif; }\n    .flow-node.input { border-left: 4px solid #7c3aed; }\n    .flow-node.deliverable { border-left: 4px solid var(--green); background: var(--green-soft); }\n    .flow-node.next { border-left: 4px solid var(--blue); background: var(--blue-soft); }\n    .flow-label { margin-bottom: 3px; color: var(--muted); font: 700 10px/1.2 "Space Grotesk", sans-serif; letter-spacing: .09em; text-transform: uppercase; }\n    .flow-node h3 { font-size: 15px; }\n    .flow-node p { margin-top: 4px; font-size: 12px; }\n    .flow-node .why { color: var(--ink); }\n    .flow-node .then { color: var(--blue); }\n    .idea-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; }\n    .idea { padding: 11px; border: 1px dashed #b8c4d4; border-radius: 3px; background: #fbfcfe; }\n    .idea strong { display: block; font: 700 13px/1.2 "Space Grotesk", sans-serif; }\n    .idea p { margin-top: 4px; color: var(--muted); font-size: 11px; }\n    .read-set { display: flex; gap: 7px; flex-wrap: wrap; }\n    .read-set a { color: var(--blue); font-size: 11px; font-weight: 700; text-decoration: none; }\n    .work-panel { background: #172033; border-color: #172033; color: #fff; box-shadow: 0 8px 0 rgba(20, 32, 52, .16), 0 24px 56px rgba(20, 32, 52, .25); }\n    .work-panel .section-title small { color: #b8c4d4; }\n    .work-panel .work-card { color: var(--ink); }\n    .optional-next { display: none; }\n    .empty { color: var(--muted); font-size: 13px; }\n    .footer { margin-top: 14px; padding: 13px 16px; color: var(--muted); font-size: 11px; text-align: center; }\n    code { padding: 2px 5px; border-radius: 3px; background: #f1f4f8; font-size: .92em; }\n    @media (max-width: 760px) { main { width: min(100% - 16px, 1060px); margin-top: 10px; } .layout, .facts, .detail-grid { grid-template-columns: 1fr; } .hero, .panel { padding: 16px; } }\n  \n    body { color: #111; overflow-wrap: anywhere; } .layout > *, .work-card { min-width: 0; } .detail-grid { grid-template-columns: 1fr; }\n    .criterion { padding: 10px 0; border-bottom: 1px solid var(--line); } .criterion:last-child { border: 0; }\n    .criterion.pass { color: var(--green); } .criterion.error, .criterion.deferred { color: var(--red); }\n    .update { color: var(--blue); } .constraints { color: var(--red); }\n    .criterion label { display: block; } a { color: var(--blue); overflow-wrap: anywhere; }\n    pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; padding: 10px; background: #f1f4f8; }\n    .worker { font-size: 12px; padding: 9px 0; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }\n    .shot { max-width: 100%; height: auto; display: block; margin-top: 8px; }\n    .card-head, .message-head, .accordion > summary { flex-wrap: wrap; }\n    .state { white-space: normal; } .fact strong { overflow-wrap: anywhere; }\n    .meta-line { font-size: 11px; color: var(--muted); margin-top: 7px; }\n    @media (max-width: 760px) { .flow { grid-template-columns: 1fr; } .flow-node + .flow-node::before { content: "↓"; left: 50%; top: -12px; } }\n'
import argparse
import hashlib
import html
import json
import os
from pathlib import Path
import re
import tempfile
from datetime import datetime, timezone
from urllib.parse import urlsplit, quote

EMAIL = re.compile(r'[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}')
SECRET = re.compile(r'(?i)(?:bearer\s+\S+|(?:api[_ -]?key|access[_ -]?token|password|secret)\s*[:=]\s*[^\s,;]+)')
ACTIVE = {'ACTIVE', 'RUNNING'}


def public_text(value):
    """Content minimization only; no authentication/access layer."""
    return SECRET.sub('[credential omitted]', EMAIL.sub('[email omitted]', str(value)))


def esc(value):
    return html.escape(public_text(value), quote=True)


def safe_ref(ref):
    if not isinstance(ref, str) or not ref.strip() or public_text(ref) != ref:
        return None
    if any(ord(c) < 32 for c in ref) or '\\' in ref:
        return None
    p = urlsplit(ref)
    if p.scheme:
        return ref if p.scheme in {'https', 'http'} and p.netloc and not p.username and not p.password else None
    if p.netloc or ref.startswith('/') or any(part == '..' for part in p.path.split('/')):
        return None
    if ':' in p.path:
        return None
    return quote(ref, safe='/#?=&%.-_~')


def link(ref, label=None):
    safe = safe_ref(ref)
    return '<a href="%s">%s</a>' % (html.escape(safe, quote=True), esc(label or ref)) if safe else '<span class="constraints">Unsafe or private reference omitted</span>'


def date(value):
    result = datetime.fromisoformat(value.replace('Z', '+00:00'))
    if result.tzinfo is None:
        raise ValueError('timestamp requires timezone')
    return result


def validate(value, schema, full, path='$'):
    """Small strict validator for the exact, bundled JSON Schema vocabulary."""
    if '$ref' in schema:
        schema = full['$defs'][schema['$ref'].split('/')[-1]]
    typ = schema.get('type')
    valid = {'object': lambda x:isinstance(x,dict), 'array':lambda x:isinstance(x,list),
             'string':lambda x:isinstance(x,str), 'integer':lambda x:isinstance(x,int) and not isinstance(x,bool),
             'boolean':lambda x:isinstance(x,bool)}
    if typ and not valid[typ](value):
        raise ValueError(path + ': expected ' + typ)
    if 'enum' in schema and value not in schema['enum']:
        raise ValueError(path + ': invalid enum')
    if isinstance(value,dict):
        props=schema.get('properties',{})
        for key in schema.get('required',[]):
            if key not in value: raise ValueError(path + ': missing ' + key)
        for key,item in value.items():
            rule=props.get(key,schema.get('additionalProperties',True))
            if rule is False: raise ValueError(path + ': unknown field ' + key)
            if isinstance(rule,dict): validate(item,rule,full,path+'.'+key)
    elif isinstance(value,list):
        for i,item in enumerate(value): validate(item,schema['items'],full,path+'['+str(i)+']')
    elif isinstance(value,str):
        if len(value.strip()) < schema.get('minLength',0): raise ValueError(path+': empty string')
        if 'pattern' in schema and not re.search(schema['pattern'],value): raise ValueError(path+': invalid pattern')
        if schema.get('format')=='date-time': date(value)
    elif isinstance(value,int) and value < schema.get('minimum',value): raise ValueError(path+': below minimum')


def observation(value, now, threshold):
    if not value: return 'UNKNOWN'
    seconds=(now-date(value)).total_seconds()
    if seconds < -60: return 'UNKNOWN (future timestamp)'
    return 'STALE' if seconds > threshold else 'FRESH SNAPSHOT'


def read_prompt(root, record):
    if 'content' in record: return record['content']
    if 'prompt_file' not in record: raise ValueError('prompt requires content or prompt_file')
    p=(root/record['prompt_file']).resolve()
    if not p.is_relative_to(root.resolve()): raise ValueError('prompt_file must stay within root')
    return p.read_text()


def load_state(root, contract):
    schema=json.loads((Path(__file__).parent/'board-status.schema.json').read_text())
    state={'schema_version':1,'criteria':{},'workers':[],'messages':[]}
    workers={}
    for worker in contract.get('agent_assignments',[]):
        validate(worker, schema['$defs']['worker'], schema, 'contract.agent_assignments')
        if worker['child_id'] in workers: raise ValueError('duplicate contract worker ID')
        workers[worker['child_id']]=worker
    paths=[root/'assignments.json', *sorted((root/'receipts').glob('*.json')), root/'status.json']
    for path in paths:
        if not path.exists(): continue
        data=json.loads(path.read_text()); validate(data,schema,schema,str(path.name))
        for key,val in data.items():
            if key=='criteria': state[key].update(val)
            elif key=='workers':
                seen=set()
                for worker in val:
                    cid=worker['child_id']
                    if cid in seen: raise ValueError('duplicate worker ID within '+path.name)
                    seen.add(cid)
                    previous=workers.get(cid)
                    if not previous or date(worker.get('observed_at','1970-01-01T00:00:00Z')) >= date(previous.get('observed_at','1970-01-01T00:00:00Z')):
                        merged=dict(worker)
                        history=[]; seen_prompts=set()
                        for prompt in (previous or {}).get('prompt_history',[])+worker.get('prompt_history',[]):
                            fingerprint=(prompt['state'],prompt.get('sha256'),prompt.get('sent_at'),prompt.get('content'),prompt.get('prompt_file'))
                            if fingerprint not in seen_prompts: history.append(prompt); seen_prompts.add(fingerprint)
                        if history: merged['prompt_history']=history
                        workers[cid]=merged
            elif key=='messages': state[key].extend(val)
            else: state[key]=val
    state['workers']=list(workers.values())
    dids={d['id'] for d in contract['deliverables']}
    cids=[c['id'] for d in contract['deliverables'] for c in d['criteria']]
    if len(cids)!=len(set(cids)) or len(dids)!=len(contract['deliverables']): raise ValueError('contract IDs must be unique')
    if set(state['criteria'])-set(cids): raise ValueError('ledger includes unknown criteria')
    executor=state.get('executor',{}).get('child_id') or contract.get('executor_id')
    for w in state['workers']:
        if set(w['deliverables'])-dids: raise ValueError('worker includes unknown deliverables')
        for p in w.get('prompt_history',[]):
            content=read_prompt(root,p)
            if not content.lstrip().startswith('/goal'): raise ValueError('worker prompt must start with /goal')
            digest=hashlib.sha256(content.encode()).hexdigest()
            if p.get('sha256') and p['sha256']!=digest: raise ValueError('prompt hash mismatch: '+w['child_id'])
            if p['state']=='SENT' and (not p.get('sha256') or not p.get('sent_at')): raise ValueError('SENT prompt requires hash and sent_at')
    state['worker_records']=[w for w in state['workers'] if w['child_id']!=executor and w['role'].lower() not in {'orchestrator','main orchestrator'}]
    return state


def criterion_state(c, ledger):
    if c['status']=='DEFERRED_BY_USER': return 'DEFERRED_BY_USER',[],''
    entry=ledger.get(c['id'],c)
    status=entry.get('status','PENDING'); proofs=entry.get('proof',[])
    normalized=[{'ref':p} if isinstance(p,str) else p for p in proofs]
    valid=[p for p in normalized if isinstance(p,dict) and safe_ref(p.get('ref'))]
    if status=='PASS' and not valid: return 'ERROR_MISSING_PROOF',[],entry.get('note','')
    return status,valid,entry.get('note','')


def render(root, now=None):
    now=now or datetime.now(timezone.utc)
    contract=json.loads((root/'contract.json').read_text()); state=load_state(root,contract)
    workers=state['worker_records']; threshold=state.get('stale_after_seconds',1200)
    rows={c['id']:criterion_state(c,state['criteria']) for d in contract['deliverables'] for c in d['criteria']}
    required=sum(v[0]!='DEFERRED_BY_USER' for v in rows.values())
    passed=sum(v[0]=='PASS' for v in rows.values()); deferred=len(rows)-required
    active=sum(w['status'] in ACTIVE for w in workers)
    executor=state.get('executor',{})
    confirmed=executor.get('startup_confirmed',False)
    run_state=state.get('run_state',contract['state'])
    observed=state.get('observed_at')
    parts=['<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="template-purpose" content="Simple parent-child LLL communication board"><meta http-equiv="refresh" content="600"><title>Verification and Constraints</title><style>'+BASE_CSS+'</style></head>']
    parts.append('<body data-run-state="'+esc(run_state)+'" data-run-id="'+esc(contract['id'])+'" data-session-id="'+esc(executor.get('child_id') or contract.get('executor_id') or 'UNCONFIRMED')+'"><main>')
    parts.append('<header class="surface hero"><div class="eyebrow">LLL · communication board</div><div class="row"><h1>Verification and Constraints</h1><span class="chip status">'+esc(run_state)+'</span></div><p class="lede">'+esc(contract['title'])+'</p><div class="chips"><span class="chip live">Executor: '+esc(executor.get('child_id') or contract.get('executor_id') or 'Startup not confirmed')+'</span><span class="chip meta">'+esc(contract['model']+' / '+contract['effort']+' / '+contract['host'])+' requested</span><span class="chip status">Startup '+('CONFIRMED' if confirmed else 'UNCONFIRMED')+'</span><span class="chip meta">Observation: '+observation(observed,now,threshold)+'</span><span class="chip meta">'+esc(observed or 'No executor observation timestamp')+'</span></div><p class="meta-line">Stable board: '+link(contract['board_url'])+' · Contract updated '+esc(contract['updated_at'])+' · Rendered '+esc(now.isoformat())+'</p><p class="meta-line">This page reloads every 600 seconds. It displays saved observations, does not poll accounts or workers, and does not run an AI heartbeat.</p></header>')
    parts.append('<div class="layout"><section class="surface panel goal" data-id="goal" data-owner="parent"><h2>Goal and finish line</h2><p><strong>End goal:</strong> '+esc(contract['end_goal'])+'</p><p style="margin-top:9px"><strong>Done means:</strong> '+esc(contract['end_state'])+'</p><div class="facts"><div class="fact"><strong>'+str(passed)+' / '+str(required)+'</strong><span>required criteria verified · '+str(len(rows))+' total · '+str(deferred)+' deferred</span></div><div class="fact"><strong>'+str(len(contract['deliverables']))+'</strong><span>deliverables</span></div><div class="fact"><strong>'+str(active)+'</strong><span>registered active workers, last observed · main orchestrator excluded</span></div></div><p class="proof">Confirmed user instruction: existing Kocsy ZIP/setup handoff is completed. The new screenshot guide is additional. Historical automation-comment cleanup is DEFERRED_BY_USER and excluded from required checks.</p></section><section class="surface panel" data-id="fixed-rules" data-owner="parent"><h2>Rules that stay fixed</h2><ul class="constraints">'+''.join('<li>'+esc(c)+'</li>' for c in contract['constraints'])+'</ul><div class="proof"><b>Proof standard:</b> PASS requires a saved nonempty proof reference. Earlier PASS evidence remains unverified until reuse is checked. Checkboxes are ledger-derived and read-only. GUI proof needs actual named screenshots and the independent review required by the contract.</div></section></div>')
    # Preserve the template flow section without a runtime diagram dependency.
    parts.append('<section class="surface panel section"><div class="section-title"><h2>Deliverable flow</h2><small>Dependencies and ownership</small></div><p class="empty">D0 handover admits work. Read-only research and isolated patches can run in parallel. Shared integration, deployment and store writes belong to one named writer and run serially. Each card lists its exact dependencies.</p><div class="flow-shell"><div class="flow">')
    for did,title,why,kind in [('flow-input-one','Confirm D0 handover','Reuse real evidence, leases and owners.','input'),('flow-input-two','Work in parallel','Read-only or separate patch paths after admission.','input'),('flow-deliverable-one','Integrate serially','One explicit deployment/store owner.','deliverable'),('flow-deliverable-two','Verify D1–D5','Real behavior, exact readbacks and screenshots.','deliverable'),('flow-output','Finish D6 handoff','Only proof-backed required criteria count.','next')]:
        parts.append('<article class="flow-node '+kind+'" data-id="'+did+'"><div class="flow-label">'+kind+'</div><h3>'+title+'</h3><p class="why">'+why+'</p></article>')
    parts.append('</div></div></section><section class="surface panel section"><div class="section-title"><h2>Parent ↔ child messages</h2><small>Saved handoffs only</small></div><div class="stack">')
    for m in reversed(state['messages']): parts.append('<article class="surface message '+esc(m['owner'])+'" data-id="message-'+esc(m['id'])+'"><div class="message-head"><strong>'+esc(m['owner'].upper())+'</strong><span>'+esc(m.get('time','UNKNOWN'))+'</span></div><p>'+esc(m['text'])+'</p></article>')
    if not state['messages']: parts.append('<p class="empty">No executor message registered. Startup has not been inferred from the requested model or host.</p>')
    parts.append('</div></section>')
    parts.append('<details class="surface panel section accordion" data-id="parent-read-set" data-owner="parent"><summary><span>Parent read set</span><small>Short, identified artifacts</small></summary><div class="accordion-body"><div class="read-set">'+'<span>Private contract.json supplies criterion provenance below.</span> '+link('board-status.schema.json','Update schema')+' '+link('render_board.py','Deterministic renderer')+'</div><h4 style="margin-top:12px">Historical boards</h4><div class="read-set">'+''.join(link(u,'Historical board '+str(i+1))+' ' for i,u in enumerate(contract.get('historical_boards',[])))+'</div></div></details>')
    parts.append('<details class="surface panel section accordion" data-id="ideas" data-owner="parent"><summary><span>Registered worker prompts</span><small>Actual history and planned assignments</small></summary><div class="accordion-body"><p class="empty">Main orchestrator excluded from worker totals. Register nested agents with exact parent_id, child_id, role, assigned cards and observation time. Adapted prompts are appended, preserving earlier sent content and hashes.</p>')
    for w in workers:
        parts.append(worker_html(w,root,now,threshold,True))
    registered_files={p.get('prompt_file') for w in state['workers'] for p in w.get('prompt_history',[])}
    for file in sorted((root/'prompts').glob('*.md')):
        rel=file.relative_to(root).as_posix()
        if rel in registered_files: continue
        content=file.read_text()
        if not content.lstrip().startswith('/goal'): continue
        parts.append('<details><summary>NOT SENT · planned '+esc(file.name)+'</summary><p class="meta-line">No child ID assigned. SHA256 '+hashlib.sha256(content.encode()).hexdigest()+'</p><pre>'+esc(content)+'</pre></details>')
    if not workers and not (root/'prompts').exists(): parts.append('<p class="empty">No workers registered and no planned prompt files yet. Initial active workers: 0.</p>')
    parts.append('</div></details><section class="surface panel section work-panel"><div class="section-title"><h2>Work and proof</h2><small>One card per deliverable</small></div><div class="stack">')
    for d in contract['deliverables']:
        rp=sum(rows[c['id']][0]=='PASS' for c in d['criteria']); rt=sum(rows[c['id']][0]!='DEFERRED_BY_USER' for c in d['criteria'])
        assigned=[w for w in workers if d['id'] in w['deliverables']]; ac=sum(w['status'] in ACTIVE for w in assigned)
        ds='VERIFIED' if rp==rt else 'BLOCKED' if any(rows[c['id']][0] in {'FAIL','BLOCKED','ERROR_MISSING_PROOF'} for c in d['criteria']) else 'ACTIVE' if ac else 'QUEUED'
        cls='done' if ds=='VERIFIED' else 'blocked' if ds=='BLOCKED' else 'active' if ac else 'next'
        parts.append('<article class="work-card '+cls+'" data-id="deliverable-'+esc(d['id'])+'" data-owner="executor" data-state="'+ds+'"><div class="card-head"><div><h3>'+esc(d['id']+' · '+d['title'])+'</h3><p class="card-copy"><b>Problem:</b> '+esc(d['problem'])+'</p></div><div><span class="state '+cls+'">'+ds+'</span><span class="counter"> '+str(rp)+' / '+str(rt)+'</span></div></div><p class="card-copy"><b>Change:</b> '+esc(d['change'])+'</p><p class="card-copy"><b>Visible location:</b> '+esc(d['location'])+'</p><p class="card-copy"><b>Representative test:</b> '+esc(d['test'])+'</p><p class="meta-line">Dependencies: '+esc(', '.join(d.get('depends_on',[])) or 'None')+' · Live writes require: '+esc(', '.join(d.get('live_write_depends_on',[])) or 'D0 ownership admission and one serial writer')+'</p><p class="update">'+str(ac)+' registered active workers on this card, last observed; '+str(len(assigned))+' assigned records.</p>')
        for w in assigned: parts.append(worker_html(w,root,now,threshold,False))
        parts.append('<details open><summary>Show criteria, constraints, and evidence</summary><div class="detail-grid"><div class="detail" data-id="criteria-'+esc(d['id'])+'" data-owner="child"><h4>Observable tests</h4>')
        for c in d['criteria']:
            cs,proof,note=rows[c['id']]; cid='criterion-'+c['id']; color='pass' if cs=='PASS' else 'deferred' if cs=='DEFERRED_BY_USER' else 'error' if cs in {'FAIL','BLOCKED','ERROR_MISSING_PROOF'} else ''
            parts.append('<div class="criterion '+color+'" data-criterion-id="'+esc(c['id'])+'" data-state="'+cs+'"><label for="'+esc(cid)+'"><input class="check" type="checkbox" id="'+esc(cid)+'" data-id="'+esc(cid)+'" disabled'+(' checked' if cs=='PASS' else '')+'> <b>'+esc(c['id'])+'</b> '+esc(c['text'])+'</label><p class="meta-line">'+cs+(' · excluded from required count' if cs=='DEFERRED_BY_USER' else '')+'</p>')
            if note: parts.append('<p class="update">'+esc(note)+'</p>')
            if c.get('change'): parts.append('<p class="update">Current change: '+esc(c['change'])+'</p>')
            parts.append('<details><summary>Original criterion provenance and proof</summary><p class="meta-line">Contract '+esc(contract['id'])+' · source '+esc(c.get('source_id',c['id']))+' · original status '+esc(c.get('prior_status',c['status']))+'</p>')
            if c.get('prior_evidence'): parts.append('<p class="meta-line">Historical evidence, reuse not yet confirmed: '+esc(c['prior_evidence'])+'</p>')
            if proof:
                for p in proof:
                    parts.append('<div class="proof">'+link(p['ref'],p.get('label') or p['ref'])+'</div>')
                    if p.get('kind')=='screenshot': parts.append('<img class="shot" loading="lazy" src="'+html.escape(safe_ref(p['ref']),quote=True)+'" alt="'+esc(p.get('label') or 'Criterion '+c['id']+' screenshot')+'">')
            else: parts.append('<p class="proof">'+('Deferred by Matt. No verification requested.' if cs=='DEFERRED_BY_USER' else 'Proof pending. No PASS claimed.')+'</p>')
            parts.append('</details></div>')
        parts.append('</div><div class="detail"><h4>Protect</h4><p class="constraints">One shared writer. No client/lead sends. Preserve human edits and original facts. Deferred cleanup cannot become a checked criterion.</p></div></div></details></article>')
    parts.append('</div></section><section class="surface panel section"><div class="section-title"><h2>Open decisions and blockers</h2><small>Unresolved saved items</small></div><div class="stack">')
    errors=[cid for cid,v in rows.items() if v[0]=='ERROR_MISSING_PROOF']
    for text in state.get('decisions',[])+(['Invalid PASS without usable proof: '+', '.join(errors)] if errors else []): parts.append('<article class="surface message blocked"><p>'+esc(text)+'</p></article>')
    if not state.get('decisions') and not errors: parts.append('<p class="empty">No decision/blocker receipt recorded. This is not proof that runtime work is unblocked.</p>')
    parts.append('</div></section><section class="surface panel section" data-id="recovery" data-owner="parent"><div class="section-title"><h2>Recovery state</h2><small>Inspect on stall or completion</small></div><div class="facts"><div class="fact"><strong>'+esc(state.get('recovery_state','UNKNOWN'))+'</strong><span>recovery state</span></div><div class="fact"><strong>'+esc(state.get('resume_point','D0 handover'))+'</strong><span>resume point</span></div><div class="fact"><strong>'+str(len(errors))+'</strong><span>invalid proof claims</span></div></div><div class="proof"><b>Parent action:</b> Confirm startup and exact handover. Update atomic status/assignment receipts, render on meaningful events, then publish the same document URL.</div></section><footer class="surface footer">Maintain this document identity. Delivered status follows the contract and proof ledger. No user click changes completion.</footer></main></body></html>')
    return ''.join(parts)


def worker_html(w,root,now,threshold,prompts):
    text='<div class="worker update"><b>'+esc(w['child_id'])+'</b> · '+esc(w['role'])+' · '+esc(w['status'])+' · '+observation(w.get('observed_at'),now,threshold)+' · '+esc(w.get('observed_at','observation time UNKNOWN'))+' · parent '+esc(w.get('parent_id','UNKNOWN'))+' · '+esc(w.get('mode','mode UNKNOWN'))+' · cards '+esc(', '.join(w['deliverables']))+'</div>'
    if prompts:
        for i,p in enumerate(w.get('prompt_history',[])):
            content=read_prompt(root,p); digest=hashlib.sha256(content.encode()).hexdigest()
            label='SENT' if p['state']=='SENT' else 'NOT SENT'
            redacted=public_text(content)!=content
            text+='<details><summary>'+label+' · '+esc(w['child_id'])+' · prompt '+str(i+1)+'</summary><p class="meta-line">SHA256 '+digest+' · '+esc(p.get('sent_at','not assigned'))+(' · Public text redacted; exact original stays in the private immutable prompt record.' if redacted else ' · Exact /goal content')+'</p>'
            if p.get('note'): text+='<p class="update">'+esc(p['note'])+'</p>'
            text+='<pre>'+esc(content)+'</pre></details>'
    return text


def atomic_write(path,content):
    fd,temp=tempfile.mkstemp(prefix='.'+path.name+'.',dir=path.parent)
    try:
        with os.fdopen(fd,'w') as stream:
            stream.write(content); stream.flush(); os.fsync(stream.fileno())
        os.chmod(temp,0o644); os.replace(temp,path)
    finally:
        if os.path.exists(temp): os.unlink(temp)


def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--root',type=Path,default=Path(__file__).resolve().parent)
    args=parser.parse_args()
    try:
        content=render(args.root); atomic_write(args.root/'verification.html',content)
    except (ValueError,KeyError,OSError,json.JSONDecodeError) as error:
        parser.exit(1,'Board unchanged: '+str(error)+'\n')
    print(str(args.root/'verification.html'))


if __name__=='__main__': main()
