#!/usr/bin/env python3
"""Follow-up report. Reuses read-only round-one raw evidence; writes only ROOT."""
import ast,collections,datetime,difflib,hashlib,json,pathlib,re,shutil,socket,subprocess,sys,tarfile
from normalize_evidence import normalize,events,ps_waits
ROOT=pathlib.Path('/tmp/lead-32913-r1/evidence-capture-3')
PREVIOUS=ROOT.with_name('evidence-capture-2')
OLD=pathlib.Path('/tmp/lead-32913-r1/evidence-capture')
BASE='bcee0f5d2872ba193c66ed9ba979bbe424f89ff9';HEAD='965b08b3060575186415df240c898a50c8736c5d'
# Link the already-built index after immutable uploads without rebuilding bundles.
if '--link-uploaded-index' in sys.argv:
 urls={}
 for name in ['ghdrop-bundles-output.md','ghdrop-scripts-output.md']:
  urls.update(re.findall(r'\[([^\]]+)\]\(([^)]+)\)',(ROOT/name).read_text()))
 target=ROOT/'index.md';text=target.read_text()
 for name,url in urls.items():text=text.replace(']('+name+')',']('+url+')')
 assert not re.search(r'\]\((?:[a-zA-Z0-9_-]+\.tar\.gz|[a-zA-Z0-9_-]+\.(?:py|txt|json))\)',text)
 target.write_text(text)
 print('Linked index to '+str(len(urls))+' immutable uploads; bundles unchanged')
 sys.exit(0)
# Preserve row notes and runtime excerpts, but execute no round-one builder actions.
source=ast.parse((OLD/'build_report.py').read_text())
for node in source.body:
 if isinstance(node,ast.FunctionDef) and node.name in ['linksha','clean','req_excerpt','excerpt','pair_files']:
  exec(compile(ast.Module(body=[node],type_ignores=[]),'round-one-report-helpers','exec'))
 if isinstance(node,ast.Assign) and any(isinstance(t,ast.Name) and t.id in ['ENV','notes'] for t in node.targets):
  exec(compile(ast.Module(body=[node],type_ignores=[]),'round-one-row-notes','exec'))

def primary_pairs():
 latest={}
 for f in sorted(OLD.glob('runs-*/summary.json')):
  if f.parent.name<'runs-20261110T103855.860142Z':continue
  for p in json.loads(f.read_text())['results']:
   p.update(kind='runtime',id=p['case']['id'],name=p['case']['name']);latest[(p['id'],p['name'])]=p
 for f in sorted(ROOT.glob('runs-*/summary.json')):
  for p in json.loads(f.read_text())['results']:
   p.update(kind='runtime',id=p['case']['id'],name=p['case']['name']);latest[(p['id'],p['name'])]=p
 pairs=list(latest.values())
 helper=sorted(OLD.glob('helper-branches-*/summary.json'))[-1]
 for p in json.loads(helper.read_text()):p['kind']='synthetic';pairs.append(p)
 reg=sorted(OLD.glob('regressions-*/result.json'))[-1];results=json.loads(reg.read_text())
 pairs.append(dict(kind='regression',id='T01',name='exact-head-regression-scenarios',before=results[0],after=results[1],folder=str(reg.parent)))
 return sorted(pairs,key=lambda p:(p['id'],p['name']))

def sha(path):return hashlib.sha256(path.read_bytes()).hexdigest()
def write(path,text):path.parent.mkdir(parents=True,exist_ok=True);path.write_text(text)
def diff(a,b,labela='before normalized',labelb='after normalized'):
 return ''.join(difflib.unified_diff(a.splitlines(True),b.splitlines(True),fromfile=labela,tofile=labelb))

def materialize(p):
 folder=pathlib.Path(p['folder']);origin=OLD if folder.is_relative_to(OLD) else PREVIOUS if folder.is_relative_to(PREVIOUS) else ROOT
 rel=folder.relative_to(origin);dest=ROOT/rel
 rawfiles=pair_files(p)
 # Raw DiffT is superseded. Diff0 and all primary raw members are byte-identical.
 for f in rawfiles:
  if f.name=='DiffT.diff':continue
  target=ROOT/f.relative_to(origin)
  if target!=f:
   target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(f,target)
  provenance.append({'path':str(target.relative_to(ROOT)),'sha256':sha(target),'source':str(f),'byte_identical':sha(f)==sha(target)})
 for side in ['before','after']:
  write(dest/side/'normalized.txt',normalize(dest/side/'transcript.txt'))
 write(dest/'DiffT.diff',diff((dest/'before/normalized.txt').read_text(),(dest/'after/normalized.txt').read_text()))
 p['artifact_folder']=str(dest)
 return dest

pairs=primary_pairs();assert len(pairs)==254
provenance=[];rows=collections.defaultdict(list)
for p in pairs:
 materialize(p);rows[p['id']].append(p)
# Matrix rows are stable; full raw matrix is linked in the index.
matrix={}
for line in pathlib.Path('/tmp/lead-32913-r1/proof-audit-1/matrix.md').read_text().splitlines():
 m=re.match(r'\|\s*((?:S|P|L|T|R)\d{2})\s*\|\s*([^|]+)\|',line)
 if m:matrix[m[1]]=m[2].strip()
assert len(matrix)==73
for id in matrix:
 dest=ROOT/'rows'/id;dest.mkdir(parents=True,exist_ok=True)
 # Keep original aggregate Diff0 byte-for-byte except the two recaptured rows.
 if id not in ['P11','P14']:
  shutil.copy2(OLD/'rows'/id/'Diff0.diff',dest/'Diff0.diff')
 else:
  write(dest/'Diff0.diff',''.join('\n# '+p['name']+'\n'+(pathlib.Path(p['artifact_folder'])/'Diff0.diff').read_text() for p in rows[id]))
 write(dest/'DiffT.diff',''.join('\n# '+p['name']+'\n'+(pathlib.Path(p['artifact_folder'])/'DiffT.diff').read_text() for p in rows[id]) if rows[id] else '# No capture: '+('cannot-verify windows' if id=='P24' else 'optional real-origin context not run')+'\n')
 write(dest/'manifest.json',json.dumps([{'variant':p['name'],'path':str(pathlib.Path(p['artifact_folder']).relative_to(ROOT))} for p in rows[id]],indent=2))

stability=[]
for session in sorted(ROOT.glob('same-revision-*/summary.json')):
 for p in json.loads(session.read_text()):
  f=pathlib.Path(p['folder'].replace(str(PREVIOUS),str(ROOT)));a=f/'head-a/transcript.txt';b=f/'head-b/transcript.txt'
  na=normalize(a);nb=normalize(b)
  write(f/'head-a/normalized.txt',na);write(f/'head-b/normalized.txt',nb)
  write(f/'DiffT.diff',diff(na,nb,'head-a normalized','head-b normalized'))
  write(f/'Diff0.diff',diff(a.read_text(),b.read_text(),'head-a raw','head-b raw'))
  stability.append({'family':p['case']['surface'],'folder':str(f.relative_to(ROOT)),'head':HEAD,'first_original_source_sha256':events(a)[0]['data']['original_source_sha256'],'second_original_source_sha256':events(b)[0]['data']['original_source_sha256'],'DiffT_bytes':(f/'DiffT.diff').stat().st_size,'normalized_sha256_first':sha(f/'head-a/normalized.txt'),'normalized_sha256_second':sha(f/'head-b/normalized.txt')})
latest_stability={p['family']:p for p in stability}
assert set(latest_stability)=={'S','P','L'} and all(p['DiffT_bytes']==0 for p in latest_stability.values()),latest_stability
write(ROOT/'same-revision-proof.json',json.dumps({'all_runs_retained':stability,'primary':list(latest_stability.values())},indent=2))
write(ROOT/'raw-integrity.json',json.dumps({'files':provenance,'all_byte_identical':all(p['byte_identical'] for p in provenance),'unchanged_aggregate_Diff0_rows':71,'changed_aggregate_Diff0_rows':['P11','P14']},indent=2))
# Validate F1/F2 from recorded raw evidence, without trusting index prose.
f1=next(p for p in pairs if p['id']=='P11' and p['name']=='status-404-W')
for side in ['before','after']:
 p=f1[side];assert p['exit']==1 and len(p['requests'])==1 and p['requests'][0]['status']==404
 assert p['requests'][0]['endpoint']=='archive' and 'Fetch $env:CAPTURE_URL $env:CAPTURE_DEST -progress' in p['output']
 assert 'CAPTURE_DISPATCH terminal=False interactive=False mechanism=W' in p['output']
 assert '>>>> Invoke-WebRequest -Uri' in p['output']
f2=[]
for p in pairs:
 if p['id']!='P14' or not p['name'].startswith('drop-'):continue
 a=p['before'];b=p['after'];assert [x['status'] for x in a['requests']]==['DROP_PREHEADER']*4
 assert [x['status'] for x in b['requests']]==['DROP_PREHEADER']*4+[504,200]
 assert a['exit']==1 and b['exit']==0
 waits=ps_waits(events(pathlib.Path(p['artifact_folder'])/'after/transcript.txt'))
 assert len(waits)==2 and abs(waits[0]['seconds']-1)<=.2 and abs(waits[1]['seconds']-2)<=.2
 output=b['output'];assert '>>>> Start-Sleep' in output
 assert ('>>>> Wait-Retry 0 $attempt' if p['name'].endswith('-H') else '>>>> -not (Wait-Retry ([int]$_.Exception.Response.StatusCode)') in output
 f2.append({'mechanism':p['case']['mech'],'before_requests':4,'before_exit':a['exit'],'after_requests':6,'after_exit':b['exit'],'requested_outer_waits_seconds':[1,2],'measured_outer_waits_seconds':[w['seconds'] for w in waits],'outer_request_calls':len(re.findall(r'>>>> (?:Invoke-(?:RestMethod|WebRequest) -Uri|\$response = \$client.GetAsync)',output))})
write(ROOT/'finding-validation.json',json.dumps({'second_followup':json.loads((ROOT/'second-followup-validation.json').read_text()),'F1_archive_IWR_404_both_revisions':True,'F2':f2,'F3_primary_pairs_regenerated':len(pairs),'same_revision_primary':list(latest_stability.values())},indent=2))
# Inspect only captured process IDs and fixture origins; never discover by pattern.
pids=set();ports=set()
owned_pairs=[p for summary in list(ROOT.glob('runs-*/summary.json'))+list(ROOT.glob('discovery-control/summary.json')) for p in json.loads(summary.read_text())['results'] if pathlib.Path(p['folder']).is_relative_to(ROOT)]
for p in owned_pairs:
 for side in ['before','after']:
  pids.update(e['pid'] for e in p[side].get('transports',[]))
  for e in p[side]['events']:
   if e['kind']=='SPAWN':pids.add(e['data']['pid'])
   if e['kind']=='MANIFEST':
    ports.update(int(x) for x in re.findall(r'127\.0\.0\.1:(\d+)',json.dumps(e['data'])))
for s in []:
 for p in json.loads(s.read_text()):
  for k in ['first','second']:
   pids.update(e['data']['pid'] for e in p[k]['events'] if e['kind']=='SPAWN')
   pids.update(e['pid'] for e in p[k]['transports'])
   ports.update(int(x) for x in re.findall(r'127\.0\.0\.1:(\d+)',json.dumps(p[k]['events'][0]['data'])))
alive=[pid for pid in sorted(pids) if pathlib.Path('/proc',str(pid)).exists()]
open_ports=[]
for port in ports:
 sock=socket.socket();sock.settimeout(.2)
 if sock.connect_ex(('127.0.0.1',port))==0:open_ports.append(port)
 sock.close()
assert not alive and not open_ports,(alive,open_ports)
cleanup={'utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),'captured_pids_checked':sorted(pids),'remaining_pids':alive,'fixture_ports_checked':sorted(ports),'open_fixture_ports':open_ports,'worktrees_created':[],'worktree_head':subprocess.check_output(['git','-C','/home/user/wt/pr-32913','rev-parse','HEAD'],text=True).strip(),'worktree_status':subprocess.check_output(['git','-C','/home/user/wt/pr-32913','status','--short'],text=True),'disk':subprocess.check_output(['df','-BG','--output=avail','/'],text=True),'dev_servers_started':0,'t3_database_access':False}
write(ROOT/'cleanup.json',json.dumps(cleanup,indent=2))
# Rebuild changed bundles on every invocation; they are new immutable uploads.
bundles=[]
for surface,name in [('S','shell-transcripts.tar.gz'),('P','powershell7-linux-transcripts.tar.gz'),('L','launcher-transcripts.tar.gz'),('T','regression-transcripts.tar.gz')]:
 files=[]
 for p in pairs:
  if not p['id'].startswith(surface):continue
  f=pathlib.Path(p['artifact_folder']);files.extend(x for x in f.rglob('*') if x.is_file() and 'node_modules' not in x.parts and not any(part in ['home','tmp','path','bin'] for part in x.relative_to(f).parts[:-1]))
  # The copied launcher lives in package/bin; include its exact source explicitly.
  for side in ['before','after']:
   q=f/side/'package/bin/t3.mjs'
   if q.exists():files.append(q)
 for id in matrix:
  if id.startswith(surface):files.extend((ROOT/'rows'/id).iterdir())
 files.extend([ROOT/'cleanup.json',ROOT/'finding-validation.json'])
 target=ROOT/name
 with tarfile.open(target,'w:gz') as tar:
  for f in sorted(set(files)):tar.add(f,arcname=str(f.relative_to(ROOT)),recursive=False)
 bundles.append(target)
# Supplemental same-head controls contain no HOME caches or executable downloads.
files=[]
for session in ROOT.glob('same-revision-*'):
 for f in session.rglob('*'):
  if not f.is_file() or any(x in ['home','tmp','path','bin','__pycache__'] for x in f.relative_to(session).parts[:-1]):continue
  files.append(f)
# Preserve every new follow-up raw run, including the earlier valid H replay.
for summary in list(ROOT.glob('runs-*/summary.json'))+list(ROOT.glob('discovery-control/summary.json')):
 files.extend([summary,summary.parent/'cases.json'])
 for pair in json.loads(summary.read_text())['results']:
  pair=dict(pair,folder=pair['folder'].replace(str(PREVIOUS),str(ROOT)))
  for f in pair_files(dict(pair,kind='runtime')):
   files.append(f)
  folder=pathlib.Path(pair['folder'])
  files.extend(folder/side/'normalized.txt' for side in ['before','after'] if (folder/side/'normalized.txt').exists())
files.extend([ROOT/'second-validation-transcript.txt',ROOT/'normalizer-change-report.json',ROOT/'second-followup-transcript.txt',ROOT/'second-followup-validation.json',ROOT/'same-revision-proof.json',ROOT/'raw-integrity.json',ROOT/'finding-validation.json',ROOT/'cleanup.json',ROOT/'FRICTION.txt'])
with tarfile.open(ROOT/'followup-validation.tar.gz','w:gz') as tar:
 for f in sorted(set(files)):tar.add(f,arcname=str(f.relative_to(ROOT)),recursive=False)
bundles.append(ROOT/'followup-validation.tar.gz')

urls={}
for fn in ['ghdrop-bundles-output.md','ghdrop-scripts-output.md']:
 f=ROOT/fn
 if f.exists():urls.update(re.findall(r'\[([^\]]+)\]\(([^)]+)\)',f.read_text()))
def link(name):return '['+name+']('+urls.get(name,name)+')'

rules='''## DiffT normalisation rules

Reused raw terminal transcripts, output, per-run result JSON, generated scripts and Diff0 remain byte-identical to earlier captures. Earlier evidence directories are read-only. P11 R receives a new timestamped pair in this follow-up; the earlier corrected P11 W and P14 pairs are retained. P11/P14 row Diff0 aggregates use those replacements. `raw-integrity.json` records hashes and exact source paths.

DiffT compares the decoded behavioural view stored as `before/normalized.txt` and `after/normalized.txt`. It groups setup, requests, waits, actions, transport calls, emitted output, and final state into stable evidence channels; read-chunk boundaries and cross-channel flush order are incidental. Request order within its channel and actual user-message order are retained.

- Absolute UTC/monotonic clocks and process startup/total duration are omitted. Measured same-URL HTTP request gaps (shell), failed fetch cancellation/rejection-to-next-call gaps (launcher), or PowerShell sleep-to-return trace gaps are retained to **0.1 seconds**; startup is excluded; shell request gaps also include the failed request and any existing progress-loop overhead. A closed-port shell fixture uses captured exec gaps. Launcher gaps exclude the failed request; PS gaps measure the sleep trace directly. Interrupted waits remain explicit. Raw transcripts retain full timing precision.
- Isolated before/after/head-a/head-b paths become `<RUN>`; scratch roots become `<SCRATCH>`; loopback ports become `<PORT>`. Revision labels, source hashes, source line numbers, and PowerShell debug source listings, and downloader command flags are metadata, retained raw but omitted from behavioural comparisons. Executed request, catch, sleep, and disposal operations remain as an ordered operation trace.
- PIDs become stable `<PID1>`, `<PID2>`, etc. in spawn/exec order, including tracked PID arrays. Counts and identity relationships remain. Random `mktemp` names become stable `<TEMP1>`, etc.; random staging suffixes are normalised consistently.
- The launcher-registry file's path-derived byte length becomes `<PATH_DERIVED_BYTES>`. PowerShell startup-cache byte lengths become `<POWERSHELL_CACHE_BYTES>`; cache existence/path remains. Download, archive, checksum, marker, and other payload lengths remain exact.
- ANSI control sequences, empty output lines, PowerShell error source frames, and native progress speed/elapsed fields are removed. Progress is represented by its observed percentage range. Actual error messages, exception/status values, content/hash output, request sequence/status, exit code, signal actions, install/staging/sidecar state, and executable result remain.

No status, exception, failed attempt, retry, exit result, or install-state difference is normalised away. The same rules apply to every primary pair and each same-revision control.
'''
parts=['# Second follow-up CLI evidence for [#32913](https://git.cafe/pingdotgg/t3code/pull/32913)\n',rules,'## Findings resolved\n',
'**F4 — operation parser:** direct Invoke-RestMethod assignments use any variable name; wrapper source listings are not counted as executed calls. All 254 primary pairs and 73 aggregates were regenerated. Normalizer-only changes: P01/latest-504; P02/nightly-page2 and preview-page2; P20/latest-missing, train-cap and train-empty. Aggregate DiffT changes: P01, P02 and P20. Reused raw captures and pair Diff0 are unchanged. See `normalizer-change-report.json` in the validation bundle.\n',
'**F5 — P11 lookup R:** the actual release-discovery caller now receives one latest-release 404 at both pinned revisions and exits 1 without retry. The corrected archive-W pair is preserved. The new healthy-discovery control makes the identical latest lookup once, receives 200, resolves version 1.2.3 and exits 0 at both pins: **DiffT is empty (0 bytes)** at `discovery-control/P01-healthy-discovery/DiffT.diff`. Its raw captures, normalized views and results are in the validation bundle.\n',
'**F1 — P11:** `status-404-W` now requests the archive via `Fetch -progress` with redirected stderr (`terminal=False`, `interactive=False`) and executes real Invoke-WebRequest. Both pinned revisions request one archive 404 and exit 1. The obsolete checksum-targeted W pair is retained in the read-only round-one evidence; it is replaced in this index.\n',
'**F2 — P14:** the mixed R/W/H fixtures now drop four consecutive requests, exhausting .NET’s native retry budget. Base makes four drops and exits 1. Head’s outer catch then waits, gets 504, waits again using the same counter, then gets 200 and exits 0. This is three outer request calls and two completed sleeps, rather than native recovery hiding the outer catch.\n',
'| Mechanism | Base | Head | Measured outer sleeps (0.1 s) |\n|---|---|---|---|']
for f in f2:parts.append('| '+f['mechanism']+' | 4 drops; exit 1 | 4 drops → 504 → 200; 3 outer calls; exit 0 | '+', '.join(f'{x:.1f} s' for x in f['measured_outer_waits_seconds'])+' |')
parts.extend(['\n**F3:** every one of the 254 primary pair DiffT files and all 73 row aggregates is regenerated. Raw evidence and reused Diff0 remain intact.\n','## Same-revision proof\n','Both executions use '+linksha(HEAD)+', independent processes, and fresh isolated homes. The same normaliser used for all rows produces the following primary results. Earlier controls, if any, are retained and listed in `same-revision-proof.json`.\n','| Family | Scenario / archive directory | DiffT bytes | Normalised hashes equal |\n|---|---|---|---|'])
for family,p in sorted(latest_stability.items()):parts.append('| '+family+' | `'+p['folder']+'` | **'+str(p['DiffT_bytes'])+'** | '+str(p['normalized_sha256_first']==p['normalized_sha256_second']).lower()+' |')
parts.extend(['\nProof data, the zero-byte files, and all new raw follow-up captures (including the earlier valid H replay) are in '+link('followup-validation.tar.gz')+'.\n','## Artifacts\n','\n'.join('- '+link(b.name) for b in bundles)+'\n- '+link('final-verification-transcript.txt')+'\n- '+link('privacy-scan.json'),'\nDrivers and fixture (new uploads):\n','\n'.join('- '+link(p.name) for p in sorted((p for p in ROOT.glob('*.py') if p.name!='update_report_tooling.py'))), '\n## Scope and capture contract\n',
'Compared '+linksha(BASE)+' with evidence head '+linksha(HEAD)+'. The read-only worktree is now at '+linksha(cleanup['worktree_head'])+'; `git diff` confirms its merge of main does not change `scripts/` relative to the evidence head. No worktree files were changed.\n',
'71/73 matrix rows have captures: 70 fully captured required rows and partially captured S30; P24 and the macOS portion of S30 remain explicitly unverified, as previously accepted. R01 is optional. Primary evidence: **254 before/after pairs / 508 executions**. Across both follow-ups, five primary pairs are replaced (P11 W and R, P14 mixed R/W/H). This second follow-up replaces only P11 R; the healthy-discovery control adds two executions outside the primary matrix. '+str(2*len(stability))+' additional executions validate same-revision normalisation. No primary timeout.\n',
'Environment: '+ENV+' Older-curl rows use actual curl/libcurl 7.68.0 and 7.74.0 HTTP-only builds. Matrix: [fixture matrix](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/bbab77b841cccfb0/matrix.md).\n',
'CLI surfaces only: full shell installer, exact PowerShell helper/caller slices on real PS7 Linux, actual Node launcher with a synthetic executable, and the existing two exact regression scenarios. This adds no Windows or macOS claim. Every fresh HOME/T3CODE_HOME is below scratch; all HTTP data is synthetic. No T3 dev server or database was used.\n',
'Each bundle has raw transcripts/output/results, exact generated source/observers, Diff0, normalised views, pair DiffT, and row aggregate diffs. Relative paths below are exact archive member paths. Unmodified round-one helper/regression drivers remain available through the [original index](https://gh-file-drop-api-prod-mi5fy3sowv63ufte.pinglabs.workers.dev/f/b8d994732b2d5109/index.md); only new/changed follow-up drivers are uploaded here.\n',
'**Measured behaviour beyond transient GitHub errors:** shell foreground fetch also retries unresolved 302; shell curl and PowerShell IWR also retry local output errors. Unchanged boundaries include post-header launcher/HttpClient stream failures, permanent 403/404, completed cache, validation errors, GNU wget native retry, BusyBox behaviour, and active launcher SIGINT staging leftovers. The detailed row notes retain these limits.\n','## Matrix rows\n'])
notes['P11']='403 in R/W/H plus 404 lookup R and archive W/H stop after one targeted request. R now runs the exact release-discovery caller, requests /pingdotgg/t3code/releases/latest once, receives 404 and exits 1 at both pins; it does not request SHA256SUMS. Corrected W capture uses noninteractive Fetch -progress against the archive; debug trace executes IWR and the original exception/status is preserved.'
notes['P14']='Fourth-attempt-success captures remain. Corrected mixed captures use four consecutive preheader drops, then 504, then 200 for R/W/H. Native .NET retries exhaust on the first four drops; head executes the outer catch, then two waits from the shared attempt counter (1 and 2 seconds requested), three outer calls, and successful final output. Base exits after the four drops. See the measured sleep table above.'
for id,title in matrix.items():
 parts.extend(['### '+id+' — '+title+'\n',notes.get(id,'')+'\n'])
 if not rows[id]:continue
 parts.extend(['| Variant / transcript directory | Before<br>'+linksha(BASE)+' | After<br>'+linksha(HEAD)+' |','|---|---|---|'])
 for p in rows[id]:
  parts.append('| `'+p['name']+'`<br>`'+str(pathlib.Path(p['artifact_folder']).relative_to(ROOT))+'` | '+excerpt(p['before'],p)+' | '+excerpt(p['after'],p)+' |')
 bundle={'S':'shell-transcripts.tar.gz','P':'powershell7-linux-transcripts.tar.gz','L':'launcher-transcripts.tar.gz','T':'regression-transcripts.tar.gz'}[id[0]]
 parts.append('\nRow diffs: `rows/'+id+'/Diff0.diff`, `rows/'+id+'/DiffT.diff` in '+link(bundle)+'.\n')
parts.extend(['## Cleanup and remaining limits\n','All captured subprocess PIDs are gone and every owned fixture port is closed. Each server was shut down and its thread joined in finally. No second worktree was created. Read-only worktree status is clean. Disk: '+cleanup['disk'].replace('\n',' ').strip()+'. All evidence is retained.\n','P24 Windows PowerShell 5.1/Windows and S30 macOS remain unverified as previously accepted. R01 optional live-origin context is not run. No additional requested follow-up capture remains missing once all primary same-revision DiffT sizes above are zero.\n','## Friction\n\n'+(ROOT/'FRICTION.txt').read_text()])
text='\n'.join(parts)
text=re.sub(r'(?m)(\|[^\n]*\n)\n+(?=\|)',r'\1',text)
write(ROOT/'index.md',text)
# Privacy scan includes every upload textual member and driver/index, not just prose.
private=re.compile(r'(?i)\b(?:gpt-[0-9a-z][0-9a-z.\-]*|claude-(?:opus|sonnet|haiku)[0-9.\-]*|codex-(?:mini|max|[0-9])[0-9a-z.\-]*)\b')
hits=[];members=0
for b in bundles:
 with tarfile.open(b) as tar:
  for m in tar:
   if m.isfile():
    members+=1
    if private.search(tar.extractfile(m).read().decode(errors='replace')):hits.append(b.name+':'+m.name)
for f in list((p for p in ROOT.glob('*.py') if p.name!='update_report_tooling.py'))+[ROOT/'index.md']:
 if private.search(f.read_text()):hits.append(f.name)
assert not hits,hits
write(ROOT/'privacy-scan.json',json.dumps({'members_scanned':members,'private_identifier_hits':hits,'synthetic_only':True},indent=2))
write(ROOT/'upload-files.json',json.dumps([str(p) for p in bundles]+[str(p) for p in sorted((p for p in ROOT.glob('*.py') if p.name!='update_report_tooling.py'))],indent=2))
print(json.dumps({'primary_pairs':len(pairs),'raw_files_checked':len(provenance),'F2':f2,'stability':list(latest_stability.values()),'privacy_scan':{'members':members,'hits':hits},'bundles':[(b.name,b.stat().st_size) for b in bundles]},indent=2))
