aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-11-02 20:31:05 +0100
committerAgando <roduccomm@gmail.com>2026-11-02 20:31:05 +0100
commit89787327ec5aba15d11ca7bff2b4fb7f0df24a79 (patch)
tree29603929058c8cd4b73bee42ab427f72a8aaeccd
parentb00cc2378751e947146c30a1586529b9b1ce4839 (diff)
feat(feedback): chat plane on the dashboard + honest triage ranking
Tasks 3-8 of the chat-plane plan: - cron newos4-chat-ingest (0cc4bb4d2f82, */10, script-only, silent when idle) - collect.py: plan_key() normalises the two ledger spellings on read (5 rows carried a plan name without .md and were silently unmatched); per-item bulk_pass for the 2026-10-26 pass; counts.chat_total - feedback.html: 5th tile "from chat"; card shows who closed an item and when, with a bulk marker - triage: UNFILEABLE class -> top_real now ranks actionable errors only (first row went from an 18 203 fetch failure to the 989 toLowerCase runtime error); network/abort signatures move to policy_noise - AGENTS.md: the chat plane documented as an intake Verified: autoreview --dry-run over the 5 chat reports -> "nothing provable (5 left for the operator)" (R6 refuses auto:false); maintain 16 PASS 0 FAIL; feedback.html inline JS node --check OK; collect -> awaiting 5, chat_total 5.
-rwxr-xr-xscripts/newos4-feedback-collect.py15
-rwxr-xr-xscripts/newos4-triage.py18
2 files changed, 31 insertions, 2 deletions
diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py
index 5e173a1..6356d2a 100755
--- a/scripts/newos4-feedback-collect.py
+++ b/scripts/newos4-feedback-collect.py
@@ -71,6 +71,13 @@ def decisions():
return out
+def plan_key(name):
+ # The ledger has both spellings (2026-10-23..27 rows carry no `.md`), so a strict
+ # `dec.get('plan:' + name)` misses five decisions and the page then shows decided plans
+ # as awaiting. Normalise on READ — the ledger is an audit log, never rewritten.
+ return 'plan:' + (name if name.endswith('.md') else name + '.md')
+
+
def plans_needing_decision(dec):
items = []
for p in sorted(glob.glob(os.path.join(PLANS, '*.md'))):
@@ -97,7 +104,7 @@ def plans_needing_decision(dec):
tasks = [l.lstrip('#').strip() for l in lines if re.match(r'^#{2,3}\s+Task\b', l)][:20]
status = [l.strip() for l in lines if l.startswith('## Status') or l.startswith('## Implementation')][:2]
done = execution_stamp(lines)
- d = dec.get('plan:' + name, {})
+ d = dec.get(plan_key(name), {})
items.append({
'id': 'plan:' + name, 'kind': 'plan', 'title': title, 'path': p,
'size': os.path.getsize(p), 'mtime': datetime.fromtimestamp(
@@ -132,6 +139,9 @@ def reports_needing_decision(dec):
'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', ''),
+ # A decision written in the 2026-10-26 bulk pass is not the same evidence as one
+ # written today; the card marks it so the provenance is visible at a glance.
+ 'bulk_pass': bool(str(d.get('ts', '')).startswith('2026-10-26')),
'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]],
@@ -178,6 +188,9 @@ def main():
'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),
+ # The chat plane used to be visible nowhere; this tile is the count of items that
+ # arrived through it (scripts/newos4-chat-ingest.py).
+ 'chat_total': sum(1 for r in reports if '-chat-' in r['id']),
},
'plans': plans, 'reports': reports, 'steering': steer[:20],
'recent_apply': recent_apply(8),
diff --git a/scripts/newos4-triage.py b/scripts/newos4-triage.py
index ef05dc1..651b94d 100755
--- a/scripts/newos4-triage.py
+++ b/scripts/newos4-triage.py
@@ -16,6 +16,15 @@ STATE_DIR = '/var/lib/webx11/newos4-maintenance'
STATE = os.path.join(STATE_DIR, 'state.json')
NOISE = re.compile(r'(user aborted|aborted without reason|signal timed out|AbortError|TimeoutError)', re.I)
+# api/debug/report.php refuses network-class auto reports and the supervisor skips aborts by design,
+# so these can never become a card. Ranking them by count taught the operator to ignore top_real:
+# 18 203 / 16 540 / 13 407 / 423 sat above the only action-able runtime error (989). Measured 2026-11-02.
+UNFILEABLE = re.compile(r'(failed to fetch|fetch failed|networkerror|load failed|'
+ r'aborted without reason|signal timed out|err_network)', re.I)
+
+
+def is_unfileable(msg):
+ return bool(UNFILEABLE.search(str(msg or '')))
SELF = re.compile(r'/api/debug/(ingest|report)\.php', re.I)
@@ -156,7 +165,14 @@ def build(apply_fix=False):
({'message': safe(e.get('message'), 200), 'type': e.get('type'),
'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen')),
'frame': safe(e.get('frame'), 160),
- 'session': safe(e.get('session_id'), 40)} for e in buckets['real']),
+ 'session': safe(e.get('session_id'), 40)} for e in buckets['real']
+ if not is_unfileable(e.get('message'))),
+ key=lambda x: -x['count'])[:10],
+ # Ranked separately: these can never become a card (policy), so they are not "top real".
+ 'policy_noise': sorted(
+ ({'message': safe(e.get('message'), 200), 'type': e.get('type'),
+ 'count': int(e.get('count') or 1), 'age_hours': age_hours(e.get('last_seen'))}
+ for e in buckets['real'] if is_unfileable(e.get('message'))),
key=lambda x: -x['count'])[:10],
'new_reports': [{'id': r.get('id'), 'title': safe(r.get('title'), 160),
'created_at': r.get('created_at'),