diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:45 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-23 14:39:45 +0200 |
| commit | fa6eca3eda89e88b01f58bc01ef60f3e7e5473fb (patch) | |
| tree | 91b09ff4d9965d3589d1845d1b107f057e88e026 | |
| parent | cfe5b7a842890c232358ae191652c5e5e81591e1 (diff) | |
fix(newos4): unmeasured skills are untracked, world-model writes verified, skill audit
External review packet (os4-external-review-v4.6.51.html) triaged against the code:
2 findings confirmed and fixed, 4 could not be reproduced and were recorded as such.
- module-memory-graph.js: getStaleSkills() treated a missing lastUsed as 999 days
unused, so skill-audit recommended archiving every skill (22 of 22). Skills with no
usage telemetry are now collected by getUntrackedSkills() and never counted stale.
Live: "Stale: 0" + "Untracked: 22 (no usage telemetry — NOT stale)".
- module-world-model.js: _saveToVFS() ignored fsWriteJSON()'s return value and promoted
whatever a truthy read returned. Now write -> read-back -> byte compare -> promote,
with every return checked. Regression test modules/test/test-world-model-save.js
(red on the old code: promoted=true with errs=[]; green now).
- scripts/newos4-skill-audit.py: 5 mechanical probes (version x3, read_file paging +
context budget, selection symbols, required scripts, extKeys) + skill presence;
exits non-zero on drift. 7/7 pass today. Weekly job cb6bc2a16216 runs it Mondays.
Not reproduced (recorded in admin/docs/newos4-external-review-verification-2026-10-23.md):
world_model_query empty-payload throw (live call returns JSON), pipes-as-command-names,
ls skills empty (23 entries via fs.readDir), ~66 agent dirs (4 live).
| -rw-r--r-- | modules/core/module-memory-graph.js | 12 | ||||
| -rw-r--r-- | modules/core/module-world-model.js | 28 | ||||
| -rw-r--r-- | modules/ext/skill-pack-core.js | 5 | ||||
| -rw-r--r-- | modules/test/test-world-model-save.js | 24 | ||||
| -rwxr-xr-x | scripts/newos4-skill-audit.py | 91 |
5 files changed, 153 insertions, 7 deletions
diff --git a/modules/core/module-memory-graph.js b/modules/core/module-memory-graph.js index ffc79e0..093d2e4 100644 --- a/modules/core/module-memory-graph.js +++ b/modules/core/module-memory-graph.js @@ -469,9 +469,16 @@ class MemoryGraphModule { // v4.5.90b fix: Use configurable _decayDays (P1 from review) const staleThreshold = this._decayDays * 24 * 60 * 60 * 1000; var stale = []; + var untracked = []; for (var name of Object.keys(index)) { var stats = index[name] || {}; if (stats.immune) continue; + // A skill with no usage telemetry has never been measured. Calling it stale is how + // the audit came to recommend archiving the system's entire procedural memory. + if (stats.lastUsed === undefined && stats.useCount === undefined) { + untracked.push(name); + continue; + } var useCount = stats.useCount || 0; var lastUsed = stats.lastUsed ? new Date(stats.lastUsed).getTime() : 0; if (useCount === 0 && (now - lastUsed > staleThreshold || lastUsed === 0)) { @@ -479,12 +486,17 @@ class MemoryGraphModule { stale.push({ name: name, daysUnused: daysUnused }); } } + this._untrackedSkills = untracked; return stale; } catch (e) { return []; } } + getUntrackedSkills() { + return Array.isArray(this._untrackedSkills) ? this._untrackedSkills : []; + } + // v4.5.89: Configure decay policy decayConfig(args) { if (args.length < 2) { diff --git a/modules/core/module-world-model.js b/modules/core/module-world-model.js index cbfd1fb..4e88f59 100644 --- a/modules/core/module-world-model.js +++ b/modules/core/module-world-model.js @@ -580,14 +580,28 @@ class WorldModelModule { if (prevModel) { try { fs.fsWriteJSON('/system/world-model.prev.json', prevModel); } catch (_) {} } // v4.5.89b fix: Clean stale temp from previous crash (P1 from review) try { fs.deleteFile('/system/world-model.tmp.json'); } catch (_) {} - fs.fsWriteJSON('/system/world-model.tmp.json', this._model); - const tmp = fs.fsReadJSON('/system/world-model.tmp.json'); - if (tmp) { - fs.fsWriteJSON('/system/world-model.json', tmp); - fs.deleteFile('/system/world-model.tmp.json'); - } else { - console.error('[WorldModel] Save failed: temp file corrupt after write'); + // Atomic + verified: never promote a write we did not read back byte-for-byte. + // Measured 2026-10-23: the old block promoted /system/world-model.json even when every + // write returned false, and logged nothing — that is how a graph got replaced by a + // partial model. fs.fsWriteJSON() returns a boolean; ignoring it hides quota failures. + const payload = JSON.stringify(this._model); + const wrote = fs.writeFile('/system/world-model.tmp.json', payload); + if (!wrote) { + console.error('[WorldModel] Save failed: write returned false (storage quota?)'); + return; } + const back = fs.readFile('/system/world-model.tmp.json'); + if (back !== payload) { + console.error('[WorldModel] Save failed: read-back mismatch (' + + (back ? back.length : 0) + '/' + payload.length + ' bytes) — keeping previous model'); + return; + } + const promoted = fs.writeFile('/system/world-model.json', back); + if (!promoted) { + console.error('[WorldModel] Save failed: promote returned false'); + return; + } + fs.deleteFile('/system/world-model.tmp.json'); } catch (e) { console.error('[WorldModel] Save error:', e.message); } diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js index f204e70..5c317a4 100644 --- a/modules/ext/skill-pack-core.js +++ b/modules/ext/skill-pack-core.js @@ -1054,8 +1054,10 @@ class SkillPackCoreModule { // 1. Stale check const mg = this.terminal?.moduleManager?.modules?.['MemoryGraphModule']; var staleSkills = []; + var untracked = []; if (mg && typeof mg.getStaleSkills === 'function') { staleSkills = mg.getStaleSkills(); + untracked = (typeof mg.getUntrackedSkills === 'function') ? mg.getUntrackedSkills() : []; } for (var s of staleSkills) { if (!index[s.name] || index[s.name].immune) continue; @@ -1093,6 +1095,9 @@ class SkillPackCoreModule { this.terminal.print('Total skills: ' + allNames.length, 'info'); this.terminal.print('Healthy: ' + healthy, 'success'); this.terminal.print('Stale: ' + stale.length + (stale.length > 0 ? ' (' + stale.join(', ') + ')' : ''), stale.length > 0 ? 'warning' : 'info'); + if (untracked.length) { + this.terminal.print('Untracked: ' + untracked.length + ' (no usage telemetry — NOT stale)', 'info'); + } this.terminal.print('Duplicates: ' + duplicates.length + (duplicates.length > 0 ? ' (' + duplicates.join(', ') + ')' : ''), duplicates.length > 0 ? 'warning' : 'info'); this.terminal.print('Broken refs: ' + brokenRefs, brokenRefs > 0 ? 'error' : 'info'); this.terminal.print('', ''); diff --git a/modules/test/test-world-model-save.js b/modules/test/test-world-model-save.js new file mode 100644 index 0000000..eabba84 --- /dev/null +++ b/modules/test/test-world-model-save.js @@ -0,0 +1,24 @@ +// modules/test/test-world-model-save.js — run: node modules/test/test-world-model-save.js +const fs = require('fs'), path = require('path'); +const src = fs.readFileSync(path.join(__dirname, '../core/module-world-model.js'), 'utf8'); +const WorldModelModule = new Function(src + '\nreturn WorldModelModule;')(); +const wm = new WorldModelModule(); +wm._model = { version: 'test', nodes: 1 }; + +const calls = []; +const fake = { + readDir: () => [], mkdir: () => {}, + readFile: () => '', + writeFile: (p) => { calls.push(p); return false; }, // every write fails + fsReadJSON: () => ({ version: 'test', nodes: 1 }), // truthy: old code would promote + fsWriteJSON: (p) => { calls.push(p); return false; }, + deleteFile: () => true, +}; +const errs = []; const orig = console.error; +console.error = (m) => errs.push(String(m)); +wm._saveToVFS(fake); +console.error = orig; + +const promoted = calls.some(p => p === '/system/world-model.json'); +console.log(JSON.stringify({ calls, errs, promoted })); +process.exit(promoted || errs.length === 0 ? 1 : 0); diff --git a/scripts/newos4-skill-audit.py b/scripts/newos4-skill-audit.py new file mode 100755 index 0000000..c10274c --- /dev/null +++ b/scripts/newos4-skill-audit.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Audit newOS4 skill claims against the live tree. Exit 1 on any drift.""" +import json, os, re, subprocess, sys + +ROOT = '/var/www/webx11.duckdns.org/spaces/newos4' +SKILLS = os.path.expanduser('~/.hermes/profiles/webx11/skills/webx11') +PAIRS = ['newos4-core-module-editing', 'newos4-module-patch-and-verify'] + + +def read(p): + try: + with open(p, encoding='utf-8') as f: + return f.read() + except Exception: + return '' + + +def probe_version(): + loader = read(os.path.join(ROOT, 'modules/core/module-loader.js')) + m = re.search(r"LATEST_VER\s*=\s*'([^']+)'", loader) + if not m: + return False, 'LATEST_VER not found in module-loader.js' + ver = m.group(1) + n = read(os.path.join(ROOT, 'main.html')).count(ver) + return n == 3, f'{ver} appears {n}x in main.html (expect 3)' + + +def probe_read_file(): + src = read(os.path.join(ROOT, 'modules/core/module-aiassist.js')) + has_offset = 'offset: { type:' in src + has_next = 'next_offset' in src + m = re.search(r'maxContextTokens\s*=\s*(\d+)', src) + return (has_offset and has_next and bool(m)), \ + f'offset={has_offset} next_offset={has_next} maxContextTokens={m.group(1) if m else None}' + + +def probe_selection(): + src = read(os.path.join(ROOT, 'modules/ext/filemgr.js')) + return ('_multiSelect' in src and '_getSelectedPaths' in src), \ + f'_multiSelect={"_multiSelect" in src} _getSelectedPaths={"_getSelectedPaths" in src}' + + +def probe_scripts(): + need = ['scripts/rebuild-bundles.py', 'scripts/lint-no-undef.py', + 'scripts/newos4-maintain.py', 'scripts/newos4-triage.py'] + missing = [p for p in need if not os.path.exists(os.path.join(ROOT, p))] + return not missing, f'missing={missing or "none"}' + + +def probe_bundle_membership(): + loader = read(os.path.join(ROOT, 'modules/core/module-loader.js')) + return "extKeys = [" in loader, 'extKeys literal present' + + +PROBES = { + 'version': probe_version, + 'read_file_contract': probe_read_file, + 'selection_symbols': probe_selection, + 'scripts': probe_scripts, + 'ext_keys': probe_bundle_membership, +} + + +def main(): + results, failed = {}, 0 + for name, fn in PROBES.items(): + try: + ok, detail = fn() + except Exception as e: + ok, detail = False, f'probe threw: {e}' + results[name] = {'ok': ok, 'detail': detail} + print(('PASS ' if ok else 'FAIL ') + name + ': ' + detail) + failed += 0 if ok else 1 + + for pair in PAIRS: + p = os.path.join(SKILLS, pair, 'SKILL.md') + results['skill_' + pair] = {'ok': os.path.exists(p), 'detail': p} + if not os.path.exists(p): + print('FAIL skill_' + pair + ': missing ' + p) + failed += 1 + + out = '/var/lib/webx11/newos4-maintenance/skill-audit.json' + os.makedirs(os.path.dirname(out), exist_ok=True) + with open(out, 'w', encoding='utf-8') as f: + json.dump(results, f, indent=2) + print(f'skill audit: {len(PROBES) - failed + len(PAIRS)}/{len(PROBES) + len(PAIRS)} pass') + return 1 if failed else 0 + + +if __name__ == '__main__': + sys.exit(main()) |