aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-26 14:50:54 +0100
committerAgando <roduccomm@gmail.com>2026-10-26 14:50:54 +0100
commitb79d70926ae88a3b10a13e2e029bbb7f487b8cf4 (patch)
treeb568c61110ad1c12dc7e7061d2676d20e507ced7
parent02ffef703a1b8d85915f344e182a1a4e67dac9df (diff)
feat(feedback): autonomous first-pass reviewer — decide what is provable, leave the rest
The operator asked: "when I don't do anything, an agent can do it for me and I can just follow up or steer it." This is that agent, deterministic (no LLM): same input, same decision, and every reason is a grep or a commit hash that can be checked. spaces/newos4/scripts/newos4-feedback-autoreview.py — rules, first match wins: R1 duplicate same fingerprint already decided -> resolve R2 already-fixed identifiers absent from modules/**/*.js AND `git log -S<token>` proves a commit removed them -> resolve R3 noise fetch/abort/probe patterns -> reject R4 still-present auto report + stack + identifiers still in code -> approve (max 2/day) R5 plan / R6 user report -> never decided Writes to the same append-only decisions.jsonl with actor=auto-reviewer, note + evidence; the applier's last-wins rule means any operator click overrides an agent decision. Kill switch: data/feedback/autonomy.json {"mode":"off"} (safe default, aggressive = cap 5). Cron job e31ac58a559e, 07:40 and 19:40, no_agent, silent unless it decided something. Fixture proof (6 cases, /tmp corpus, git repo with a real removal commit): RPT-A duplicate -> resolve (duplicate of already-decided RPT-B) RPT-C already-fixed -> resolve (goneFromCode removed from modules/ in fc4eaf3) RPT-D noise -> reject RPT-E still-present -> approve RPT-F user report -> SKIP RPT-G never existed -> SKIP <- caught the first draft, which called it "already fixed" Live dry-run on the real corpus: "nothing provable (0 left for the operator, mode=safe)" and `git log -S emptyTrash -- spaces/newos4/modules` resolves to 563a9be, i.e. the report I wrongly approved by hand tonight would have been auto-resolved correctly. Dashboard: agent-decided cards carry a 🤖 badge + the reason + "Any button below overrides it", and the tab shows "🤖 feedback agent: N decided on its own in its last run (safe mode, M left for you)".
-rwxr-xr-xscripts/newos4-feedback-autoreview.py240
-rwxr-xr-xscripts/newos4-feedback-collect.py5
2 files changed, 244 insertions, 1 deletions
diff --git a/scripts/newos4-feedback-autoreview.py b/scripts/newos4-feedback-autoreview.py
new file mode 100755
index 0000000..e99a516
--- /dev/null
+++ b/scripts/newos4-feedback-autoreview.py
@@ -0,0 +1,240 @@
+#!/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'
+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
+
+
+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)
+ skip = {'error', 'failed', 'fetch', 'undefined', 'cannot', 'reading', 'properties', 'uncaught',
+ 'invalid', 'unexpected', 'token', 'https', 'http', 'com', 'org', 'null', 'true', 'false'}
+ return [t for t in IDENT.findall(cleaned) if t.lower() not in skip][:4]
+
+
+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 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)
+ 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.
+ if stack and present and cap_left > 0:
+ return 'approve', 'still present in shipped code (stack + identifiers found)', {
+ 'present_tokens': present, 'hits': {t: hits[t] for t in present}}
+ if stack and present and cap_left <= 0:
+ return None, 'daily auto-approve cap reached — left for the operator', {'present_tokens': present}
+
+ return None, 'not provable by the rules — left for the operator', {
+ 'tokens': tokens, 'present': present, 'stack': stack}
+
+
+def main():
+ global REPORTS, MODULES, DECISIONS, ROOTDIR
+ 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('--include-plans', action='store_true', help='never used in production')
+ a = ap.parse_args()
+ ROOTDIR = a.root
+ REPORTS, MODULES, DECISIONS = a.reports_dir, a.modules, a.decisions
+
+ 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())
diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py
index 226a74c..c0711d9 100755
--- a/scripts/newos4-feedback-collect.py
+++ b/scripts/newos4-feedback-collect.py
@@ -17,6 +17,7 @@ STEER = os.path.join(ROOT, 'spaces/newos4/data/maintenance/steering.json')
DECISIONS = os.path.join(ROOT, 'spaces/newos4/data/feedback/decisions.jsonl')
APPLIED = os.path.join(ROOT, 'spaces/newos4/data/feedback/applied.json') # ledger written by the applier
NETFAIL = os.path.join(ROOT, 'spaces/newos4/data/debug/network_failures.json') # ingest-filter counter
+AUTOREVIEW = os.path.join(ROOT, 'spaces/newos4/data/feedback/autoreview-latest.json')
def load(path, default):
@@ -80,7 +81,7 @@ def plans_needing_decision(dec):
os.path.getmtime(p), timezone.utc).isoformat(timespec='seconds'),
'excerpt': excerpt, 'tasks': tasks, 'status': status,
'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'),
- 'note': d.get('note', ''),
+ 'note': d.get('note', ''), 'actor': d.get('actor', ''),
})
return items
@@ -103,6 +104,7 @@ def reports_needing_decision(dec):
'created_at': r.get('created_at'), 'auto': bool(diag.get('auto')),
'new': rid in new_ids, 'decision': d.get('decision'),
'decided_at': d.get('decided_at') or d.get('ts'), 'note': d.get('note', ''),
+ 'actor': d.get('actor', ''),
'message': (r.get('body') or '')[:1500], 'page': diag.get('url') or '',
'version': diag.get('version') or '', 'ua': (diag.get('ua') or '')[:120],
'errors': [str(e)[:300] for e in errs[:3]],
@@ -147,6 +149,7 @@ def main():
},
'plans': plans, 'reports': reports, 'steering': steer[:20],
'recent_apply': recent_apply(8),
+ 'autoreview': {k: (load(AUTOREVIEW, {}) or {}).get(k) for k in ('generated_at', 'mode', 'decided', 'skipped')},
'triage_checks': (triage.get('checks') or {}),
'triage_generated_at': triage.get('generated_at'),
}