diff options
| -rw-r--r-- | help.html | 12 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 159 | ||||
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 159 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rwxr-xr-x | scripts/newos4-feedback-apply.py | 203 | ||||
| -rwxr-xr-x | scripts/newos4-feedback-collect.py | 147 |
7 files changed, 681 insertions, 7 deletions
@@ -483,7 +483,9 @@ diff a.txt b.txt</pre> <!-- ── Commands ── --> <section id="commands"> - <h2>📖 Full Command Reference</h2> + <p>📖 <b>New to feedback?</b> <a href="/feedback-guide.html">How feedback works</a> — which channel to use, what gets sent, and when to expect an answer.</p> + +<h2>📖 Full Command Reference</h2> <div class="callout"> <strong>⚠️ There is no shell.</strong> The terminal splits your line into argv and hands it to @@ -557,6 +559,14 @@ diff a.txt b.txt</pre> <tr><td><code>debug-trends</code></td><td>Error frequency over time, grouped by day and type</td><td><code>debug-trends 7</code></td></tr> <tr><td><code>debug-fix</code></td><td>Suggest and optionally auto-apply fix for an error</td><td><code>debug-fix a1b2c3d4</code></td></tr> <tr><td><code>debug-health</code></td><td>Check DebugSupervisorModule health status</td><td><code>debug-health</code></td></tr> + <tr><td><code>feedback <message></code></td><td>Send a message to the operator agent (alias <code>hermes</code>); <code>feedback -- <text></code> forces a message</td><td><code>feedback download link 403s on mobile</code></td></tr> + <tr><td><code>feedback list [status]</code></td><td>List stored /stear reports — status <code>new</code>, <code>read</code> or <code>archived</code> (admin)</td><td><code>feedback list new</code></td></tr> + <tr><td><code>feedback read <id></code></td><td>Show one report in full, including any reply (admin)</td><td><code>feedback read 12</code></td></tr> + <tr><td><code>feedback reply <id> <text></code></td><td>Publish a reply — the reporter sees it in /stear → My reports; status becomes read (admin)</td><td><code>feedback reply 12 shipped in v4.6.92</code></td></tr> + <tr><td><code>feedback archive|reopen <id></code></td><td>Move a report between archived and new (admin)</td><td><code>feedback archive 12</code></td></tr> + <tr><td><code>feedback stats</code></td><td>Counts per status plus the dashboard URL (admin)</td><td><code>feedback stats</code></td></tr> + <tr><td><code>feedback dashboard</code></td><td>Open /admin/feedback.html in a new tab (admin)</td><td><code>feedback dashboard</code></td></tr> + <tr><td><code>feedback-admin <sub></code></td><td>Admin alias for the feedback subcommands (<code>list|read|reply|archive|reopen|stats|dashboard</code>)</td><td><code>feedback-admin stats</code></td></tr> </table></div> @@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.91"></script> +<script src="modules/core/core-bundle.js?v=v4.6.94"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.91"></script> +<script src="modules/core/module-loader.js?v=v4.6.94"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.91"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.94"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 8e19189..32ff73b 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -20753,6 +20753,9 @@ class DebugSupervisorModule { this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); this.commands['hermes-reset'] = () => this._cmdChatReset(); + // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still + // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback). + this.commands['feedback-admin'] = (args) => this._fbDispatch(args); } seed() { @@ -21650,9 +21653,25 @@ class DebugSupervisorModule { } _cmdFeedback(args) { - const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const argv = raw.split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + // A subcommand only when its ARGUMENTS are plausible. Measured failure without this + // check: "feedback list is broken on mobile" became a filtered list of reports. + const STATUS = ['', 'new', 'read', 'archived']; + const isNum = (v) => /^\d+$/.test(v || ''); + const forced = raw.startsWith('--'); + const isSub = !forced && ( + (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) || + ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) || + (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) || + (sub === 'reply' && isNum(argv[1]) && argv.length >= 3) + ); + if (isSub) { this._fbDispatch(args); return; } + const msg = forced ? raw.replace(/^--\s*/, '') : raw; if (!msg) { this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error'); + this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', ''); return; } this.terminal.print('📨 Sending…', ''); @@ -21688,6 +21707,144 @@ class DebugSupervisorModule { }); } + // ── feedback management (admin) ────────────────────────────────────────── + _fbHelp() { + const rows = [ + 'feedback <message> send a message to the operator agent (alias: hermes)', + 'feedback list [status] list stored /stear reports (new|read|archived, default all)', + 'feedback read <id> show one report in full', + 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)', + 'feedback archive <id> archive a report', + 'feedback reopen <id> move a report back to new', + 'feedback stats counts per status', + 'feedback dashboard open /admin/feedback.html in a new tab', + 'feedback-inbox read agent replies from the chat bridge', + ]; + this.terminal.print('feedback — commands', ''); + rows.forEach(r => this.terminal.print(' ' + r, '')); + this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', ''); + } + + async _fbToken() { + if (!this._fbKey) { + const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status); + const d = await r.json(); + if (!d || !d.token) throw new Error('no admin token in response'); + this._fbKey = d.token; + } + const w = Math.floor(Date.now() / 1000 / 300) * 300; + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _fbCall(path, opts) { + const tok = await this._fbToken(); + const o = Object.assign({ credentials: 'same-origin' }, opts || {}); + o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' }, + (opts && opts.headers) || {}); + const r = await fetch('/api/feedback' + path, o); + if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); } + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + } + + _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); } + + _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); } + + _fbList(status) { + const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50'; + this.terminal.print('📋 loading…', ''); + this._fbCall('/list' + q).then(d => { + const items = d.items || []; + if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; } + this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) + + this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', ''); + items.forEach(it => this.terminal.print( + this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) + + this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) + + String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), '')); + const total = (typeof d.total === 'number' ? d.total : items.length); + const more = total > items.length ? ' (showing ' + items.length + ' of ' + total + + ' — feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : ''; + this.terminal.print(total + ' item(s)' + more + ' — feedback read <id>, reply <id> <text>', ''); + }).catch(e => this._fbFail(e)); + } + + _fbRead(id) { + if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; } + const want = String(id); + // The server understands ?id= since v4.6.93's API change; an older process ignores it and + // returns the newest row instead, so verify the id before trusting the answer. + this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => { + const hit = (d.items || []).find(x => String(x.id) === want); + if (hit) return hit; + return this._fbCall('/list?limit=200').then(d2 => + (d2.items || []).find(x => String(x.id) === want) || null); + }).then(it => { + if (!it) { this.terminal.print('no report #' + want, 'error'); return; } + this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), ''); + this.terminal.print('page: ' + (it.page || '—') + ' at: ' + (it.created_at || ''), ''); + this.terminal.print('', ''); + this.terminal.print(String(it.message || ''), ''); + if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); } + this.terminal.print('', ''); + this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, ''); + }).catch(e => this._fbFail(e)); + } + + _fbReply(id, text) { + if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', + body: JSON.stringify({ status: 'read', admin_reply: text }) }) + .then(() => this.terminal.print('✅ reply published on #' + id + ' (the user sees it in /stear → My reports)', '')) + .catch(e => this._fbFail(e)); + } + + _fbStatus(id, status) { + if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) }) + .then(() => this.terminal.print('✅ #' + id + ' → ' + status, '')) + .catch(e => this._fbFail(e)); + } + + _fbStats() { + const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : '')) + .then(d => { + if (typeof d.total === 'number') return d.total; // COUNT(*) from the server + return this._fbCall('/list?limit=200' + (st ? '&status=' + st : '')) + .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length); + }); + Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => { + this.terminal.print('feedback: ' + all + ' total — new ' + nw + ', read ' + rd + + ', archived ' + ar, ''); + this.terminal.print('dashboard: /admin/feedback.html', ''); + }).catch(e => this._fbFail(e)); + } + + _fbDashboard() { + this.terminal.print('🪟 opening /admin/feedback.html …', ''); + window.open('/admin/feedback.html', '_blank'); + } + + _fbDispatch(args) { + const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + const arg = argv[1]; + const rest = argv.slice(2).join(' '); + switch (sub) { + case 'list': return this._fbList(arg || ''); + case 'read': return this._fbRead(arg); + case 'reply': return this._fbReply(arg, rest); + case 'archive': return this._fbStatus(arg, 'archived'); + case 'reopen': return this._fbStatus(arg, 'new'); + case 'stats': return this._fbStats(); + case 'dashboard': return this._fbDashboard(); + case 'help': default: return this._fbHelp(); + } + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js index 25e0566..ed97aff 100644 --- a/modules/core/module-debug-supervisor.js +++ b/modules/core/module-debug-supervisor.js @@ -63,6 +63,9 @@ class DebugSupervisorModule { this.commands['feedback-inbox'] = () => this._cmdFeedbackInbox(); this.commands['hermes-poll'] = () => this._cmdFeedbackInbox(); this.commands['hermes-reset'] = () => this._cmdChatReset(); + // Admin management of stored /stear reports (2026-10-26). `feedback <text>` still + // sends a chat message: a subcommand needs plausible arguments (see _cmdFeedback). + this.commands['feedback-admin'] = (args) => this._fbDispatch(args); } seed() { @@ -960,9 +963,25 @@ class DebugSupervisorModule { } _cmdFeedback(args) { - const msg = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const raw = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim(); + const argv = raw.split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + // A subcommand only when its ARGUMENTS are plausible. Measured failure without this + // check: "feedback list is broken on mobile" became a filtered list of reports. + const STATUS = ['', 'new', 'read', 'archived']; + const isNum = (v) => /^\d+$/.test(v || ''); + const forced = raw.startsWith('--'); + const isSub = !forced && ( + (sub === 'list' && STATUS.indexOf((argv[1] || '').toLowerCase()) >= 0) || + ((sub === 'stats' || sub === 'dashboard' || sub === 'help') && argv.length === 1) || + (['read', 'archive', 'reopen'].indexOf(sub) >= 0 && isNum(argv[1])) || + (sub === 'reply' && isNum(argv[1]) && argv.length >= 3) + ); + if (isSub) { this._fbDispatch(args); return; } + const msg = forced ? raw.replace(/^--\s*/, '') : raw; if (!msg) { this.terminal.print('usage: hermes <message> (alias: feedback <message>)', 'error'); + this.terminal.print(' feedback list|read|reply|archive|reopen|stats|dashboard|help', ''); return; } this.terminal.print('📨 Sending…', ''); @@ -998,6 +1017,144 @@ class DebugSupervisorModule { }); } + // ── feedback management (admin) ────────────────────────────────────────── + _fbHelp() { + const rows = [ + 'feedback <message> send a message to the operator agent (alias: hermes)', + 'feedback list [status] list stored /stear reports (new|read|archived, default all)', + 'feedback read <id> show one report in full', + 'feedback reply <id> <t> publish a reply (status becomes read, user sees it)', + 'feedback archive <id> archive a report', + 'feedback reopen <id> move a report back to new', + 'feedback stats counts per status', + 'feedback dashboard open /admin/feedback.html in a new tab', + 'feedback-inbox read agent replies from the chat bridge', + ]; + this.terminal.print('feedback — commands', ''); + rows.forEach(r => this.terminal.print(' ' + r, '')); + this.terminal.print(' admin subcommands need a valid admin session (log in at /admin-login.html)', ''); + } + + async _fbToken() { + if (!this._fbKey) { + const r = await fetch('/admin/api/admin-token.php', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(r.status === 302 || r.redirected ? 'admin session required' : 'token HTTP ' + r.status); + const d = await r.json(); + if (!d || !d.token) throw new Error('no admin token in response'); + this._fbKey = d.token; + } + const w = Math.floor(Date.now() / 1000 / 300) * 300; + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this._fbKey + ':' + w)); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + async _fbCall(path, opts) { + const tok = await this._fbToken(); + const o = Object.assign({ credentials: 'same-origin' }, opts || {}); + o.headers = Object.assign({ 'X-Admin-Token': tok, 'Content-Type': 'application/json' }, + (opts && opts.headers) || {}); + const r = await fetch('/api/feedback' + path, o); + if (r.status === 403) { this._fbKey = null; throw new Error('admin token rejected (rejected window, retry)'); } + if (!r.ok) throw new Error('HTTP ' + r.status); + return r.json(); + } + + _fbPad(s, n) { s = String(s == null ? '' : s); return s.length >= n ? s.slice(0, n - 1) + '…' : s + ' '.repeat(n - s.length); } + + _fbFail(e) { this.terminal.print('❌ feedback: ' + (e && e.message ? e.message : e), 'error'); } + + _fbList(status) { + const q = status ? '?status=' + encodeURIComponent(status) + '&limit=50' : '?limit=50'; + this.terminal.print('📋 loading…', ''); + this._fbCall('/list' + q).then(d => { + const items = d.items || []; + if (!items.length) { this.terminal.print('no reports' + (status ? ' with status ' + status : ''), ''); return; } + this.terminal.print(this._fbPad('#', 6) + this._fbPad('status', 10) + this._fbPad('cat', 10) + + this._fbPad('user', 12) + this._fbPad('date', 12) + 'message', ''); + items.forEach(it => this.terminal.print( + this._fbPad(it.id, 6) + this._fbPad(it.status, 10) + this._fbPad(it.category, 10) + + this._fbPad(it.username || 'anon', 12) + this._fbPad(String(it.created_at || '').slice(0, 10), 12) + + String(it.message || '').replace(/\s+/g, ' ').slice(0, 60), '')); + const total = (typeof d.total === 'number' ? d.total : items.length); + const more = total > items.length ? ' (showing ' + items.length + ' of ' + total + + ' — feedback list ' + (status || '') + ' with a higher limit via the dashboard)' : ''; + this.terminal.print(total + ' item(s)' + more + ' — feedback read <id>, reply <id> <text>', ''); + }).catch(e => this._fbFail(e)); + } + + _fbRead(id) { + if (!id) { this.terminal.print('usage: feedback read <id>', 'error'); return; } + const want = String(id); + // The server understands ?id= since v4.6.93's API change; an older process ignores it and + // returns the newest row instead, so verify the id before trusting the answer. + this._fbCall('/list?limit=1&id=' + encodeURIComponent(want)).then(d => { + const hit = (d.items || []).find(x => String(x.id) === want); + if (hit) return hit; + return this._fbCall('/list?limit=200').then(d2 => + (d2.items || []).find(x => String(x.id) === want) || null); + }).then(it => { + if (!it) { this.terminal.print('no report #' + want, 'error'); return; } + this.terminal.print('#' + it.id + ' ' + it.status + ' ' + it.category + ' ' + (it.username || 'anonymous'), ''); + this.terminal.print('page: ' + (it.page || '—') + ' at: ' + (it.created_at || ''), ''); + this.terminal.print('', ''); + this.terminal.print(String(it.message || ''), ''); + if (it.admin_reply) { this.terminal.print('', ''); this.terminal.print('reply (' + (it.replied_at || '') + '): ' + it.admin_reply, ''); } + this.terminal.print('', ''); + this.terminal.print('feedback reply ' + it.id + ' <text> | feedback archive ' + it.id, ''); + }).catch(e => this._fbFail(e)); + } + + _fbReply(id, text) { + if (!id || !text) { this.terminal.print('usage: feedback reply <id> <text>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', + body: JSON.stringify({ status: 'read', admin_reply: text }) }) + .then(() => this.terminal.print('✅ reply published on #' + id + ' (the user sees it in /stear → My reports)', '')) + .catch(e => this._fbFail(e)); + } + + _fbStatus(id, status) { + if (!id) { this.terminal.print('usage: feedback ' + (status === 'new' ? 'reopen' : 'archive') + ' <id>', 'error'); return; } + this._fbCall('/' + encodeURIComponent(id), { method: 'PATCH', body: JSON.stringify({ status: status }) }) + .then(() => this.terminal.print('✅ #' + id + ' → ' + status, '')) + .catch(e => this._fbFail(e)); + } + + _fbStats() { + const one = (st) => this._fbCall('/list?limit=1' + (st ? '&status=' + st : '')) + .then(d => { + if (typeof d.total === 'number') return d.total; // COUNT(*) from the server + return this._fbCall('/list?limit=200' + (st ? '&status=' + st : '')) + .then(d2 => (d2.items || []).filter(x => !st || x.status === st).length); + }); + Promise.all([one(''), one('new'), one('read'), one('archived')]).then(([all, nw, rd, ar]) => { + this.terminal.print('feedback: ' + all + ' total — new ' + nw + ', read ' + rd + + ', archived ' + ar, ''); + this.terminal.print('dashboard: /admin/feedback.html', ''); + }).catch(e => this._fbFail(e)); + } + + _fbDashboard() { + this.terminal.print('🪟 opening /admin/feedback.html …', ''); + window.open('/admin/feedback.html', '_blank'); + } + + _fbDispatch(args) { + const argv = (Array.isArray(args) ? args.join(' ') : String(args || '')).trim().split(/\s+/); + const sub = (argv[0] || '').toLowerCase(); + const arg = argv[1]; + const rest = argv.slice(2).join(' '); + switch (sub) { + case 'list': return this._fbList(arg || ''); + case 'read': return this._fbRead(arg); + case 'reply': return this._fbReply(arg, rest); + case 'archive': return this._fbStatus(arg, 'archived'); + case 'reopen': return this._fbStatus(arg, 'new'); + case 'stats': return this._fbStats(); + case 'dashboard': return this._fbDashboard(); + case 'help': default: return this._fbHelp(); + } + } + _cmdSync(args) { const url = (args && args[0]) ? args[0] : null; if (url) { diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 3ab51d7..7d851ee 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.91'; + const LATEST_VER = 'v4.6.94'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/scripts/newos4-feedback-apply.py b/scripts/newos4-feedback-apply.py new file mode 100755 index 0000000..38188fb --- /dev/null +++ b/scripts/newos4-feedback-apply.py @@ -0,0 +1,203 @@ +#!/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 '<!-- autonomy: review -->' line to '<!-- autonomy: safe -->' + 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() == '<!-- autonomy: review -->') + + +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<!-- %s by operator via /admin/feedback.html %s%s -->\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, '<!-- autonomy: review -->', '<!-- autonomy: safe -->', note) + elif decision == 'reject': + # only meaningful if a previous run already approved it + if src.count('<!-- autonomy: safe -->') == 0: + return 'recorded (no action: was never approved)' + body, err = _flip_marker(path, src, '<!-- autonomy: safe -->', '<!-- autonomy: review -->', 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) + return str(d.get('title') or d.get('message') or rid)[:160] + except Exception: + return rid + + +def apply_report(name, decision, note): + if decision != 'approve': + return 'recorded (no action)' + text = '%s — %s' % (report_title(name), name) + if note: + 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()) 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()) |