aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 14:39:45 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 14:39:45 +0200
commitfa6eca3eda89e88b01f58bc01ef60f3e7e5473fb (patch)
tree91b09ff4d9965d3589d1845d1b107f057e88e026
parentcfe5b7a842890c232358ae191652c5e5e81591e1 (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.js12
-rw-r--r--modules/core/module-world-model.js28
-rw-r--r--modules/ext/skill-pack-core.js5
-rw-r--r--modules/test/test-world-model-save.js24
-rwxr-xr-xscripts/newos4-skill-audit.py91
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())