#!/usr/bin/env python3 """Apply queued operator decisions from data/feedback/decisions.jsonl. Runs as darkbot (cron). Mutates exactly two things, both assert-guarded: approve + plan -> flip the exact '' line to '' reject + plan -> flip it back (an approval that was reconsidered before the night run) approve + report -> append a high-priority directive via scripts/newos4-steer.py add Every decision is recorded in data/feedback/applied.json (darkbot-owned, 0600) so nothing is applied twice and php never needs write access to the sources. Ordering rules (2026-10-26 review): * the LAST decision per item wins — approve followed by reject must not ship the plan; * a decision that is superseded in the same run is recorded, not executed; * one failing item never aborts the run, and applied.json is written after every mutation. """ import json, os, re, subprocess, sys from datetime import datetime, timezone ROOT = '/var/www/webx11.duckdns.org' FB = os.path.join(ROOT, 'spaces/newos4/data/feedback') DEC = os.path.join(FB, 'decisions.jsonl') APPLIED = os.path.join(FB, 'applied.json') STEER = os.path.join(ROOT, 'spaces/newos4/scripts/newos4-steer.py') PLANS = os.path.join(ROOT, '.hermes/plans') REPORTS = os.path.join(ROOT, 'spaces/newos4/data/reports') ROTATE_BYTES = 1024 * 1024 # move decisions.jsonl aside once it passes 1 MB def load_applied(): try: with open(APPLIED, encoding='utf-8') as f: d = json.load(f) return d if isinstance(d, dict) else {} except Exception: return {} def save_applied(d): os.makedirs(FB, exist_ok=True) tmp = APPLIED + '.tmp' with open(tmp, 'w', encoding='utf-8') as f: json.dump(d, f, indent=1, ensure_ascii=False) os.chmod(tmp, 0o600) # www-data must not be able to wipe the ledger os.replace(tmp, APPLIED) def read_decisions(): rows = [] if not os.path.exists(DEC): return rows with open(DEC, encoding='utf-8', errors='ignore') as f: for i, line in enumerate(f): line = line.strip() if not line: continue try: d = json.loads(line) except Exception: continue # torn write: skip, never crash the run d['_row'] = i rows.append(d) return rows def _marker_count(src): return sum(1 for l in src.splitlines() if l.strip() == '') def _flip_marker(path, src, old, new, note): """Flip one standalone marker LINE (not a substring) and keep the file mode.""" lines = src.splitlines(keepends=True) hits = [i for i, l in enumerate(lines) if l.strip() == old] if len(hits) != 1: return None, 'error: expected 1 standalone %s line, found %d' % (old, len(hits)) i = hits[0] indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())] eol = '\n' if lines[i].endswith('\n') else '' lines[i] = '%s%s%s' % (indent, new, eol) stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%MZ') body = ''.join(lines) if not body.endswith('\n'): body += '\n' body += '\n\n' % ( 'approved' if new.endswith('safe -->') else 'reverted', stamp, (' — ' + note.replace('-->', '')) if note else '') return body, None def apply_plan(name, decision, note): plans_dir = os.path.realpath(PLANS) path = os.path.realpath(os.path.join(plans_dir, name)) if os.path.dirname(path) != plans_dir: return 'error: refused, outside plans dir' if not re.fullmatch(r'[A-Za-z0-9._-]+\.md', os.path.basename(path)): return 'error: refused, bad plan name' try: src = open(path, encoding='utf-8').read() mode = os.stat(path).st_mode & 0o777 except Exception as e: return 'error: unreadable (%s)' % e if decision == 'approve': if _marker_count(src) != 1: return 'error: expected 1 review marker, found %d' % _marker_count(src) body, err = _flip_marker(path, src, '', '', note) elif decision == 'reject': # only meaningful if a previous run already approved it if src.count('') == 0: return 'recorded (no action: was never approved)' body, err = _flip_marker(path, src, '', '', note) else: return 'recorded (no action)' if err or body is None: return err or 'error: no output produced' tmp = path + '.tmp' with open(tmp, 'w', encoding='utf-8') as f: f.write(body) os.chmod(tmp, mode) # os.replace would otherwise apply the umask (600 -> 644) os.replace(tmp, path) return 'plan %s (%s, mode %o)' % ('approved' if decision == 'approve' else 'reverted', decision, mode) def report_title(rid): """The bare id is useless in a directive once the report file rotates away.""" try: with open(os.path.join(REPORTS, rid + '.json'), encoding='utf-8') as f: d = json.load(f) t = str(d.get('title') or d.get('message') or rid)[:160] return t[6:] if t.startswith('Auto: ') else t # the 'Auto: ' prefix is redundant in a directive except Exception: return rid def apply_report(name, decision, note): if decision != 'approve': return 'recorded (no action)' title = report_title(name) text = '%s — %s' % (title, name) if note and note.strip() != title.strip(): # the note often repeats the title text = '%s — %s' % (text, note) r = subprocess.run([sys.executable, STEER, 'add', text, '--priority', 'high'], capture_output=True, text=True, timeout=60) if r.returncode != 0: return 'error: steer add exit %d (%s)' % (r.returncode, (r.stderr or '')[-120:]) return 'steering directive added' def main(): applied = load_applied() rows = read_decisions() if not rows: return 0 # last decision per item wins; everything earlier in the file is superseded last_row = {} for d in rows: last_row[d.get('item_id', '')] = d['_row'] done = 0 for d in rows: item = d.get('item_id', '') dec = d.get('decision', '') note = d.get('note', '') or '' key = '%s|%s|%s' % (d.get('ts', ''), item, dec) if key in applied: continue kind, _, name = item.partition(':') if last_row.get(item) != d['_row']: applied[key] = {'item_id': item, 'decision': dec, 'result': 'superseded by a later decision', 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')} save_applied(applied) continue try: if kind == 'plan': result = apply_plan(name, dec, note) elif kind == 'report': result = apply_report(name, dec, note) else: result = 'error: unknown item kind' except subprocess.TimeoutExpired: result = 'error: steer add timed out' except Exception as e: # one bad item must not replay the batch result = 'error: %s: %s' % (type(e).__name__, e) applied[key] = {'item_id': item, 'decision': dec, 'result': result, 'applied_at': datetime.now(timezone.utc).isoformat(timespec='seconds')} save_applied(applied) done += 1 print(('APPLY FAILED %s: %s' if result.startswith('error') else 'applied %s -> %s (%s)') % ((item, result) if result.startswith('error') else (item, dec, result))) # keep the queue file bounded; applied.json stays as the permanent ledger try: if os.path.getsize(DEC) > ROTATE_BYTES: os.replace(DEC, DEC + '.1') print('rotated decisions.jsonl -> decisions.jsonl.1 (%d bytes)' % ROTATE_BYTES) except Exception: pass return 0 if __name__ == '__main__': raise SystemExit(main())