#!/usr/bin/env python3 """Autonomous first-pass reviewer for the WebX11 feedback queue. Decides ONLY what it can prove, appends the decision to data/feedback/decisions.jsonl tagged actor=auto-reviewer, and prints one line per action (silent when it did nothing — the cron contract). The operator's later click always wins: the applier keeps the last row per item. Modes in data/feedback/autonomy.json: off | safe (default) | aggressive. """ import argparse, glob, json, os, re, subprocess, sys from datetime import datetime, timezone ROOT = '/var/www/webx11.duckdns.org' ROOTDIR = ROOT # overridable for fixture runs REPORTS = f'{ROOT}/spaces/newos4/data/reports' MODULES = f'{ROOT}/spaces/newos4/modules' DECISIONS = f'{ROOT}/spaces/newos4/data/feedback/decisions.jsonl' AUTONOMY = f'{ROOT}/spaces/newos4/data/feedback/autonomy.json' SUMMARY = f'{ROOT}/spaces/newos4/data/feedback/autoreview-latest.json' ERRORS = f'{ROOT}/spaces/newos4/data/debug/errors.json' # symptom store (count/last_seen) STALE_DAYS = 5 # quiet this long => stale artefact CAPS = {'safe': 2, 'aggressive': 5} NOISE = re.compile(r'fetch failed|failed to fetch|network ?error|load failed|user aborted' r'|signal is aborted|PROBE|probe-only|boom from|\bone-off test\b|\btest only\b', re.I) IDENT = re.compile(r'[A-Za-z_$][\w$]{4,}') def load_json(path, default): try: with open(path, encoding='utf-8') as f: return json.load(f) except Exception: return default def mode(cap_override=None): m = str((load_json(AUTONOMY, {}) or {}).get('mode') or 'safe').lower() if m not in CAPS: return 'off', 0 return m, cap_override if cap_override is not None else CAPS[m] def decisions(): out = {} if not os.path.exists(DECISIONS): return out with open(DECISIONS, encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: d = json.loads(line) except Exception: continue out[d.get('item_id', '')] = d return out def approved_today(): today = datetime.now(timezone.utc).strftime('%Y-%m-%d') n = 0 if not os.path.exists(DECISIONS): return 0 with open(DECISIONS, encoding='utf-8') as f: for line in f: try: d = json.loads(line) except Exception: continue if (str(d.get('ts', '')).startswith(today) and d.get('actor') == 'auto-reviewer' and d.get('decision') == 'approve'): n += 1 return n # Generic vocabulary: JS keywords, builtins and DOM handler names. A token in here identifies the # LANGUAGE, not the defect, so it can never justify "still present in shipped code" (2026-11-14). # Before this, `cmds.indexOf is not a function` produced present_tokens ["indexOf","function"] with # hits {indexOf: 36, function: 74} and became a HIGH steering directive for a function that does not # exist anywhere in the tree (plan 2026-11-13_062052 §2). SKIP = {'error', 'failed', 'fetch', 'undefined', 'cannot', 'reading', 'properties', 'uncaught', 'invalid', 'unexpected', 'token', 'https', 'http', 'com', 'org', 'null', 'true', 'false', # language / builtins 'function', 'return', 'indexof', 'lastindexof', 'tolowercase', 'touppercase', 'tostring', 'length', 'push', 'pop', 'slice', 'splice', 'split', 'join', 'replace', 'concat', 'shift', 'map', 'filter', 'reduce', 'foreach', 'json', 'parse', 'stringify', 'object', 'array', 'promise', 'async', 'await', 'then', 'catch', 'finally', 'typeof', 'constructor', 'prototype', 'valueof', 'hasownproperty', 'assign', 'keys', 'entries', 'values', # host objects and event plumbing 'window', 'document', 'console', 'navigator', 'localstorage', 'settimeout', 'setinterval', 'addeventlistener', 'removeeventlistener', 'queryselector', 'onclick', 'onload', 'onerror', 'eval', 'anonymous', 'innerhtml', 'textcontent', 'classlist', 'dispatchevent'} # `at isCommand (:34:17)`, `at t.executeCommand (:66:12)`, # `at HTMLButtonElement.onclick (…/main.html:954:454)` -> the function name is the only frame part # that can identify code, and it was never parsed before. STACK_FN = re.compile(r'at\s+(?:async\s+)?([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(') def identifiers(msg): """Distinctive names in an error message: drop numbers/quoted parts, keep real tokens.""" cleaned = re.sub(r'["\'].*?["\']', ' ', str(msg)) cleaned = re.sub(r'0x[0-9a-f]+', ' ', cleaned, flags=re.I) return [t for t in IDENT.findall(cleaned) if t.lower() not in SKIP][:4] def stack_functions(stack): """Function names from stack frames, most specific last segment, generics removed.""" out = [] for name in STACK_FN.findall(str(stack or '')): base = name.split('.')[-1] if not base or base.startswith('<') or base.lower() in SKIP: continue if base not in out: out.append(base) return out[:4] ANON_FRAME = re.compile(r'at\s+[^\n]*\((?::\d+:\d+)?\)') REAL_FRAME = re.compile(r'at\s+[^\n]*\((?:https?:)?//?[^)\s]+\.(?:js|mjs|html)(?:[:?][^)\s]*)?\)') def stack_generated(stack): """True when the stack describes runtime-generated code rather than a shipped file. `` frames come from `new Function(...)` module loading and the `js`/`js-run` eval path — a snippet someone pasted, not our source. A function name from such a frame says nothing about shipped code, which is exactly how `cmds.indexOf` became a HIGH directive (2026-11-12). Rule: anonymous frames present AND no frame pointing at a real .js/.html file. """ frames = [ln.strip() for ln in str(stack or '').splitlines() if ln.strip().startswith('at ')] if not frames: return False # The ORIGIN is the top frame. The bottom `HTMLButtonElement.onclick (…/main.html)` frame proves # only that a click started it, so deciding on "does any frame name a real file" got this wrong. top = frames[0] return '' in top or not REAL_FRAME.search(top) def distinctive(tokens, hits): """Tokens specific enough to justify 'still present': >=6 chars, in modules/, not generic.""" return [t for t in tokens if len(t) >= 6 and hits.get(t) and t.lower() not in SKIP] def code_hits(tokens): """Which tokens still appear in shipped modules (grep -rlF, no shell).""" hits = {} for t in tokens: try: r = subprocess.run(['grep', '-rlF', '--include=*.js', t, MODULES], capture_output=True, text=True, timeout=20) files = [ln for ln in r.stdout.splitlines() if '.orig.js' not in ln] except Exception: files = [] hits[t] = len(files) return hits def token_history(tokens): """Commits that added or removed each identifier under modules/ (git log -S). This is the proof an "already fixed" verdict needs: the token is gone from HEAD *and* git shows it once existed, so a commit removed it rather than it never being there. Without this, a report about a string that never lived in the code reads as fixed (fixture RPT-G caught it). """ out = {} for t in tokens: try: r = subprocess.run(['git', '-C', ROOTDIR, 'log', '--oneline', '-S', t, '--', os.path.relpath(MODULES, ROOTDIR)], capture_output=True, text=True, timeout=30) out[t] = [ln for ln in r.stdout.splitlines() if ln.strip()] except Exception: out[t] = [] return out def _age_days(ts): """Days since an ISO-8601 stamp; tolerant of Z, fractional seconds and bare datetimes.""" s = str(ts or '').strip().replace('Z', '+00:00') if not s: return None try: dt = datetime.fromisoformat(s) except ValueError: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0 def error_stats(msg): """count/last_seen for one exact message in the debug error store; None when unknown.""" rows = load_json(ERRORS, []) if not isinstance(rows, list): return None key = str(msg or '').strip() for e in rows: if isinstance(e, dict) and str(e.get('message') or '').strip() == key: return {'count': int(e.get('count') or 0), 'last_seen': str(e.get('last_seen') or '')} return None def fingerprints(): """fingerprint -> id, for reports that already exist (duplicate detection).""" out = {} for p in glob.glob(f'{REPORTS}/RPT-*.json'): d = load_json(p, {}) fp = d.get('fingerprint') if fp: out.setdefault(fp, []).append(d.get('id') or os.path.basename(p)[:-5]) return out def classify(rep, dec, fps, cap_left): """Return (decision, reason, evidence) or (None, why_skipped, evidence).""" rid = rep.get('id') or '' title = str(rep.get('title') or '') diag = rep.get('diagnostics') or {} errs = [e for e in (diag.get('errors') or []) if isinstance(e, dict)] msg = ' '.join(str(e.get('message') or '') for e in errs) or title stack = any(e.get('stack') for e in errs) auto = bool(diag.get('auto')) # R6 first: a human wrote it, a human answers it. if not auto: return None, 'user report — left for the operator', {} # R1 duplicate: same fingerprint already decided. fp = rep.get('fingerprint') twins = [x for x in fps.get(fp, []) if x != rid] if fp and twins and any(('report:' + t) in dec for t in twins): done = [t for t in twins if ('report:' + t) in dec] return 'resolve', f'duplicate of already-decided {done[0]}', {'fingerprint': fp, 'twin': done[0]} # R3 noise: test artefact / client-side network drop. noise_hit = NOISE.search(title) or NOISE.search(msg) if noise_hit: return 'reject', 'noise: test artefact or client-side network failure', {'match': noise_hit.group(0)} tokens = identifiers(msg) for fn in stack_functions(stack): # the frame names are the most specific identifiers we get if fn not in tokens: tokens.append(fn) hits = code_hits(tokens) if tokens else {} present = [t for t in tokens if hits.get(t)] absent = [t for t in tokens if not hits.get(t)] # R2 already-fixed: every distinctive token gone AND git proves it was removed (not never there). if tokens and not present: hist = token_history(tokens) proven = {t: c for t, c in hist.items() if c} if proven: t0 = next(iter(proven)) return 'resolve', f'already fixed: "{t0}" was removed from modules/ in {proven[t0][0].split()[0]}', { 'absent_tokens': absent, 'removed_in': proven[t0][0], 'history_tokens': list(proven)} return None, 'identifiers gone but git shows they never existed — needs a human', { 'absent_tokens': absent} # R4 still-present: real stack error whose identifiers still exist in shipped code. distinct = distinctive(present, hits) if stack and stack_generated(stack): return None, 'stack frames are (generated code — js-run / new Function), not shipped source — needs a human', { 'present_tokens': present, 'generated_stack': True} if stack and distinct and cap_left > 0: return 'approve', 'still present in shipped code (stack + identifiers found)', { 'present_tokens': present, 'distinctive_tokens': distinct, 'hits': {t: hits[t] for t in present}} if stack and distinct and cap_left <= 0: return None, 'daily auto-approve cap reached — left for the operator', {'present_tokens': distinct} if stack and present and not distinct: # Generic-only match (indexOf/function/length...): a human must decide, because "the word # appears in 74 files" is not evidence the defect is in shipped code. return None, 'only generic identifiers in message — needs a human', { 'present_tokens': present, 'hits': {t: hits[t] for t in present}} # R5 stale artefact: one occurrence that never came back, and no stack to act on. # Distinct from R2 (identifier removed) — here the emitter is still there but the symptom is not. if errs and not stack and all(int(e.get('count') or 0) <= 1 for e in errs): stats = [error_stats(str(e.get('message') or '')) for e in errs] stats = [s for s in stats if s and s.get('last_seen')] ages = [_age_days(s['last_seen']) for s in stats] if len(stats) == len(errs) and ages and all(a is not None for a in ages): quiet = max(a for a in ages if a is not None) if quiet >= STALE_DAYS and all(s['count'] <= 1 for s in stats): return 'resolve', (f'stale single-occurrence auto report: no stack, count 1, ' f'quiet {quiet:.1f}d — reopen if it recurs'), { 'counts': [s['count'] for s in stats], 'last_seen': [s['last_seen'] for s in stats], 'quiet_days': round(quiet, 1), 'emitter_tokens': tokens} return None, 'not provable by the rules — left for the operator', { 'tokens': tokens, 'present': present, 'stack': stack} SELF_CASES = [ ("identifiers drop generic words", identifiers('cmds.indexOf is not a function'), []), ("stack names are the specific identifiers", stack_functions("at isCommand (:34:17)\n" " at t.executeCommand (:66:12)\n" " at HTMLButtonElement.onclick (https://x/main.html:954:454)"), ['isCommand', 'executeCommand']), ("generics cannot justify 'still present'", distinctive(['indexOf', 'function'], {'indexOf': 36, 'function': 74}), []), ("a >=6-char shipped name still can", distinctive(['indexOf', 'executeCommand'], {'indexOf': 36, 'executeCommand': 12}), ['executeCommand']), ("anonymous-origin stack is generated code", stack_generated("TypeError: x\n at isCommand (:34:17)\n" " at HTMLButtonElement.onclick (https://x/main.html:954:454)"), True), ("shipped-origin stack is not generated", stack_generated("TypeError: x\n at Terminal.dispatchCommand (https://x/core-bundle.js:1:2)"), False), ] def self_test(): """Regression cover for the 2026-11-12 false positive (plan 2026-11-13_062052 §2). `--self-test` — run it after touching the identifier or stack parsers. A false "still present" turns a non-defect into a HIGH steering directive, which is worse than a missed report. """ bad = 0 for label, got, want in SELF_CASES: ok = got == want bad += 0 if ok else 1 print(('PASS ' if ok else 'FAIL ') + label + ('' if ok else ' got=%r want=%r' % (got, want))) print('%d/%d self-tests pass' % (len(SELF_CASES) - bad, len(SELF_CASES))) return 0 if not bad else 1 def main(): global REPORTS, MODULES, DECISIONS, ROOTDIR, ERRORS ap = argparse.ArgumentParser() ap.add_argument('--root', default=ROOT) ap.add_argument('--dry-run', action='store_true') ap.add_argument('--reports-dir', default=REPORTS) ap.add_argument('--decisions', default=DECISIONS) ap.add_argument('--modules', default=MODULES) ap.add_argument('--cap', type=int, default=None) ap.add_argument('--errors', default=ERRORS, help='debug error store (fixtures)') ap.add_argument('--include-plans', action='store_true', help='never used in production') ap.add_argument('--self-test', action='store_true', help='run identifier/stack regression checks and exit') a = ap.parse_args() if a.self_test: return self_test() ROOTDIR = a.root REPORTS, MODULES, DECISIONS = a.reports_dir, a.modules, a.decisions ERRORS = a.errors m, cap = mode(a.cap) if m == 'off': return 0 # silent: autonomy disabled cap_left = max(0, cap - approved_today()) dec = decisions() fps = fingerprints() rows, skipped = [], [] for p in sorted(glob.glob(f'{REPORTS}/RPT-*.json')): rep = load_json(p, {}) rid = 'report:' + (rep.get('id') or os.path.basename(p)[:-5]) if not rep or rid in dec: continue # already decided → operator or a previous run decision, why, ev = classify(rep, dec, fps, cap_left) if decision: rows.append({'ts': datetime.now(timezone.utc).isoformat(timespec='seconds'), 'item_id': rid, 'decision': decision, 'note': why, 'actor': 'auto-reviewer', 'evidence': ev}) if decision == 'approve': cap_left -= 1 else: skipped.append({'item_id': rid, 'why': why, 'evidence': ev}) summary = {'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'), 'mode': m, 'cap_left': cap_left, 'decided': len(rows), 'skipped': len(skipped), 'rows': rows, 'skips': skipped[:20]} if not a.dry_run: if rows: with open(DECISIONS, 'a', encoding='utf-8') as f: for r in rows: f.write(json.dumps(r, ensure_ascii=False) + '\n') tmp = SUMMARY + '.tmp' os.makedirs(os.path.dirname(SUMMARY), exist_ok=True) with open(tmp, 'w', encoding='utf-8') as f: json.dump(summary, f, ensure_ascii=False, indent=1) os.replace(tmp, SUMMARY) for r in rows: print(f"{r['decision']:<8} {r['item_id'][:40]:<42} {r['note']}") if not rows: print(f'autoreview: nothing provable ({len(skipped)} left for the operator, mode={m})' if a.dry_run else '', end='') return 0 if __name__ == '__main__': sys.exit(main())