aboutsummaryrefslogtreecommitdiff
path: root/scripts/newos4-feedback-collect.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/newos4-feedback-collect.py')
-rwxr-xr-xscripts/newos4-feedback-collect.py147
1 files changed, 147 insertions, 0 deletions
diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py
new file mode 100755
index 0000000..66f1653
--- /dev/null
+++ b/scripts/newos4-feedback-collect.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+"""Snapshot agent-side review items for /admin/feedback.html.
+
+Runs as darkbot (Hermes cron): .hermes/plans/*.md are 600 darkbot, unreadable to php-fpm,
+so the snapshot is the only way the page can see them. Writes atomically, 644 www-data.
+Read-only with respect to every source file.
+"""
+import glob, json, os, re, time
+from datetime import datetime, timezone
+
+ROOT = '/var/www/webx11.duckdns.org'
+OUT_DIR = os.path.join(ROOT, 'admin/docs/feedback')
+OUT = os.path.join(OUT_DIR, 'agent-items.json')
+PLANS = os.path.join(ROOT, '.hermes/plans')
+TRIAGE = os.path.join(ROOT, 'admin/docs/newos4-triage-latest.json')
+STEER = os.path.join(ROOT, 'spaces/newos4/data/maintenance/steering.json')
+DECISIONS = os.path.join(ROOT, 'spaces/newos4/data/feedback/decisions.jsonl')
+
+
+def load(path, default):
+ try:
+ with open(path, encoding='utf-8') as f:
+ return json.load(f)
+ except Exception:
+ return default
+
+
+def decisions():
+ """Latest decision per item id, from the append-only log written by php."""
+ out = {}
+ if not os.path.exists(DECISIONS):
+ return out
+ try:
+ 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 # torn line: ignore, never a crash
+ out[d.get('item_id', '')] = d
+ except Exception:
+ pass
+ return out
+
+
+def plans_needing_decision(dec):
+ items = []
+ for p in sorted(glob.glob(os.path.join(PLANS, '*.md'))):
+ try:
+ with open(p, encoding='utf-8', errors='ignore') as fh:
+ head = ''.join(fh.readline() for _ in range(40))
+ except Exception:
+ continue
+ # Exact-line match: the analyser contract puts the marker on the line after the
+ # title. A substring test also matches plans that merely *document* the marker
+ # (measured 2026-10-26: this plan's own audit table inflated the count to 4).
+ if not any(l.strip() == '<!-- autonomy: review -->' for l in head.splitlines()):
+ continue
+ name = os.path.basename(p)
+ title = next((l.lstrip('# ').strip() for l in head.splitlines() if l.startswith('# ')), name)
+ d = dec.get('plan:' + name, {})
+ items.append({
+ 'id': 'plan:' + name, 'kind': 'plan', 'title': title, 'path': p,
+ 'size': os.path.getsize(p), 'mtime': datetime.fromtimestamp(
+ os.path.getmtime(p), timezone.utc).isoformat(timespec='seconds'),
+ 'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'),
+ 'note': d.get('note', ''),
+ })
+ return items
+
+
+def reports_needing_decision(dec):
+ triage = load(TRIAGE, {}) or {}
+ new_ids = {r.get('id') for r in (triage.get('new_reports') or [])}
+ items = []
+ for p in sorted(glob.glob(os.path.join(ROOT, 'spaces/newos4/data/reports/*.json'))):
+ try:
+ r = json.load(open(p, encoding='utf-8'))
+ except Exception:
+ continue
+ rid = r.get('id') or os.path.basename(p)[:-5]
+ d = dec.get('report:' + rid, {})
+ items.append({
+ 'id': 'report:' + rid, 'kind': 'report', 'title': (r.get('title') or rid)[:180],
+ 'created_at': r.get('created_at'), 'auto': bool((r.get('diagnostics') or {}).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', ''),
+ })
+ items.sort(key=lambda x: (x.get('created_at') or ''), reverse=True)
+ return items
+
+
+def recent_apply(n):
+ """Last n decision outcomes, newest first — failures included, so the dashboard can show them."""
+ try:
+ with open(APPLIED, encoding='utf-8') as f:
+ d = json.load(f)
+ rows = [v for v in d.values() if isinstance(v, dict)]
+ rows.sort(key=lambda r: str(r.get('applied_at') or ''), reverse=True)
+ return rows[:n]
+ except Exception:
+ return []
+
+
+def main():
+ dec = decisions()
+ steer_raw = load(STEER, [])
+ steer_ok = isinstance(steer_raw, list)
+ steer = [s for s in (steer_raw if steer_ok else []) if isinstance(s, dict) and not s.get('done')]
+ triage = load(TRIAGE, {}) or {}
+ plans = plans_needing_decision(dec)
+ reports = reports_needing_decision(dec)
+ awaiting = [x for x in plans + reports if not x.get('decision')]
+ doc = {
+ 'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'),
+ 'counts': {
+ 'plans_review': len(plans), 'reports_total': len(reports),
+ 'awaiting_decision': len(awaiting),
+ 'steering_pending': len(steer) if steer_ok else -1, # -1 = steering.json unreadable/wrong shape
+ 'triage_reports_new': (triage.get('counts') or {}).get('reports_new', 0),
+ },
+ 'plans': plans, 'reports': reports, 'steering': steer[:20],
+ 'recent_apply': recent_apply(8),
+ 'triage_checks': (triage.get('checks') or {}),
+ 'triage_generated_at': triage.get('generated_at'),
+ }
+ os.makedirs(OUT_DIR, exist_ok=True)
+ tmp = OUT + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f:
+ json.dump(doc, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, OUT)
+ os.chmod(OUT, 0o644)
+ try:
+ import grp
+ os.chown(OUT, -1, grp.getgrnam('www-data').gr_gid)
+ except Exception:
+ pass
+ print(f"feedback snapshot: plans={len(plans)} reports={len(reports)} "
+ f"awaiting={len(awaiting)} steering={len(steer)} -> {OUT}")
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())