total-recall-brain 3.41.2 → 3.41.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/total-recall.mjs +9 -3
- package/frontend/dist/assets/{index-BG9NkVUy.css → index-BIQmqDKP.css} +1 -1
- package/frontend/dist/assets/{index-BtuiZqwP.js → index-DFenQQEQ.js} +74 -79
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +8 -5
- package/frontend/src/api/auth.ts +0 -3
- package/frontend/src/components/plugins/PluginPanel.tsx +7 -3
- package/frontend/src/components/plugins/card/CardEngine.tsx +1 -1
- package/frontend/src/pages/LoginPage.tsx +1 -41
- package/frontend/src/pages/PluginsPage.tsx +14 -7
- package/package.json +2 -2
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +6 -3
- package/scaffold/.agent/skills/total-recall/plugins/auth/cli.mjs +197 -31
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +8 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/DESIGN.md +56 -8
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/card.js +1068 -204
- package/scaffold/.agent/skills/total-recall/plugins/auth/ui/panel.js +4 -2
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +436 -274
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/ui/card.js +461 -242
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -7
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/DESIGN.md +17 -2
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/ui/card.js +426 -300
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/ui/card.js +350 -283
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +31 -19
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +16 -2
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +380 -298
- package/scaffold/.agent/skills/total-recall/plugins/security/cli.mjs +7 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +6 -6
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/security/src/scanner.mjs +17 -3
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/DESIGN.md +38 -15
- package/scaffold/.agent/skills/total-recall/plugins/security/ui/card.js +60 -34
- package/src/cli/command.mjs +23 -18
- package/src/cli/daemon.mjs +23 -1
- package/src/cli/plugin/copies.mjs +8 -5
- package/src/cli/plugin/index.mjs +25 -2
- package/src/cli/rebuild.mjs +22 -0
- package/src/cli/reset-password.mjs +3 -2
- package/src/cli/skill.mjs +8 -2
- package/src/cli/status.mjs +25 -7
- package/src/core/automatic-brain-updates.mjs +9 -3
- package/src/core/brain-updates.mjs +63 -5
- package/src/core/global-brain-updates.mjs +21 -1
- package/src/core/mesh-brain-updates.mjs +5 -2
- package/src/core/meta-harness.mjs +16 -41
- package/src/core/plugin-runner.mjs +6 -1
- package/src/core/plugin-store.mjs +67 -1
- package/src/core/plugin-updates.mjs +1 -1
- package/src/core/portable-skills-maintenance.mjs +19 -0
- package/src/core/required-plugins.mjs +7 -0
- package/src/core/skills-registry.mjs +33 -3
- package/src/server/auth.mjs +41 -1
- package/src/server/memory-app.mjs +2 -1
- package/src/server/routes/auth.mjs +25 -1
- package/src/server/routes/plugins.mjs +17 -4
package/src/cli/status.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import fs from 'node:fs';
|
|
|
21
21
|
import crypto from 'node:crypto';
|
|
22
22
|
import { loadQueue } from '../core/research-queue.mjs';
|
|
23
23
|
|
|
24
|
-
import { totalRecallToken } from '../core/config.mjs';
|
|
24
|
+
import { totalRecallToken, globalBrainDir } from '../core/config.mjs';
|
|
25
25
|
import { resolveBrainDir, resolveSurfaceDir } from './agent-dir.mjs';
|
|
26
26
|
|
|
27
27
|
function parseArgs(args) {
|
|
@@ -82,6 +82,24 @@ function projectionsStatus(clients, instructionsMtime) {
|
|
|
82
82
|
});
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The brain id the server knows this brain by. Project brain.json files carry
|
|
87
|
+
* no layer/name, so the id comes from the global project registry. Without it
|
|
88
|
+
* every status run inside a project compared the project's INSTRUCTIONS.md with
|
|
89
|
+
* the server's GLOBAL copy and reported "In sync: no" for two healthy brains.
|
|
90
|
+
*/
|
|
91
|
+
export function serverBrainId(brainDir, brainConfig, registryPath = path.join(globalBrainDir, 'config', 'project-registry.json')) {
|
|
92
|
+
if (brainConfig?.layer === 'project' && brainConfig?.name) return `project:${brainConfig.name}`;
|
|
93
|
+
try {
|
|
94
|
+
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
95
|
+
const own = path.resolve(brainDir);
|
|
96
|
+
const project = Array.isArray(registry) ? registry.find((p) => p?.brainDir && path.resolve(p.brainDir) === own) : null;
|
|
97
|
+
return project?.name ? `project:${project.name}` : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
function sha256(buf) {
|
|
86
104
|
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
87
105
|
}
|
|
@@ -199,10 +217,7 @@ export default async function statusCmd(args) {
|
|
|
199
217
|
|
|
200
218
|
let remote = null;
|
|
201
219
|
if (brainUrl) {
|
|
202
|
-
|
|
203
|
-
? `project:${brainConfig.name}`
|
|
204
|
-
: null;
|
|
205
|
-
remote = await probeBrain(brainUrl, token, brainId);
|
|
220
|
+
remote = await probeBrain(brainUrl, token, serverBrainId(brainDir, brainConfig));
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
const inSync = !!(localInstructions && remote?.instructions_sha256 &&
|
|
@@ -250,7 +265,7 @@ export default async function statusCmd(args) {
|
|
|
250
265
|
// Load ongoing research agenda
|
|
251
266
|
let ongoingResearch = [];
|
|
252
267
|
try {
|
|
253
|
-
ongoingResearch = loadQueue().filter(item => item.status === 'in_progress' || item.status === 'pending');
|
|
268
|
+
ongoingResearch = loadQueue(brainDir).filter(item => item.status === 'in_progress' || item.status === 'pending');
|
|
254
269
|
report.research = ongoingResearch.map(item => ({
|
|
255
270
|
id: item.id,
|
|
256
271
|
topic: item.topic,
|
|
@@ -284,7 +299,10 @@ export default async function statusCmd(args) {
|
|
|
284
299
|
console.log(` Remote INSTR.: ${remote.instructions_bytes} bytes, sha=${remote.instructions_sha256.slice(0, 12)}…`);
|
|
285
300
|
}
|
|
286
301
|
console.log(` In sync: ${inSync ? 'yes' : 'no'}`);
|
|
287
|
-
|
|
302
|
+
// Push sync only exists for remote deploys; a local server serves this brain
|
|
303
|
+
// directly, so "never" there read as a fault that was not one.
|
|
304
|
+
const pushSync = deployMode !== 'local' || report.last_sync || report.targets.length;
|
|
305
|
+
console.log(` Last sync: ${report.last_sync || (pushSync ? 'never' : 'not used (local deploy serves this brain directly)')}`);
|
|
288
306
|
console.log(` Sync targets: ${report.targets.length ? report.targets.join(', ') : '(none)'}`);
|
|
289
307
|
|
|
290
308
|
if (clientProjections.length) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { maintainPortableSkills } from './portable-skills-maintenance.mjs';
|
|
1
2
|
/** The existing TR daemon maintains both published brain packages, not only TR. */
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -35,7 +36,7 @@ export function startAutomaticBrainUpdates({brainDir,intervalMs=60*60*1000,run=r
|
|
|
35
36
|
return {tick,stop(){stopped=true;clearInterval(timer);}};
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,now=Date.now}={}) {
|
|
39
|
+
export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false,skipThrottle=false,intervalMs=60*60*1000,update=updateAllBrains,compile=maintainCliSurfaces,globalUpdate=updateGlobalBrainPackages,meshUpdate=updateMeshBrainPackages,sourceUpdate=updateSourceBrainPackages,pluginUpdate=updateInstalledPlugins,skillMaintenance=maintainPortableSkills,now=Date.now}={}) {
|
|
39
40
|
if(!force && !isPackageAutoUpdateEnabled())return {skipped:true,reason:'disabled',results:[]};
|
|
40
41
|
const file=path.join(brainDir,'memory-derived/automatic-brain-updates.json');
|
|
41
42
|
let saved;try{saved=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}
|
|
@@ -61,8 +62,13 @@ export async function runAutomaticBrainUpdates({brainDir,fleet=false,force=false
|
|
|
61
62
|
const plugins=await pluginUpdate({roots:[path.resolve(brainDir,'../../..')]});
|
|
62
63
|
const mesh=fleet && summary.versions?await meshUpdate({versions:summary.versions,version:summary.versions['total-recall-brain']}):{failed:0,results:[],scope:'local-only'};
|
|
63
64
|
const changed=summary.updated>0 || plugins.updated>0 || global.results.some(row=>row.status==='updated') || sources.results.some(row=>row.status==='updated') || JSON.stringify(saved?.versions)!==JSON.stringify(summary.versions);
|
|
64
|
-
const
|
|
65
|
-
|
|
65
|
+
const skills=await skillMaintenance({brainDir});
|
|
66
|
+
const surfaces=await compile({brainDir,force:changed || skills.updated > 0});
|
|
67
|
+
// local_failed is this node's own maintenance. Peers judge a node by it, not by
|
|
68
|
+
// `failed`, which also counts the node's view of its peers: judging by that made
|
|
69
|
+
// every fleet pass mark every peer failed forever, each because the others had.
|
|
70
|
+
const local_failed=(summary.failed || 0)+global.failed+sources.failed+plugins.failed+(skills.failed || 0)+(surfaces.ok?0:surfaces.results.filter(row=>row.status==='failed').length);
|
|
71
|
+
report={...summary,global,sources,plugins,mesh,skills,surfaces,local_failed,failed:local_failed+mesh.failed,checkedAt:now()};
|
|
66
72
|
}catch(error){report={failed:1,error:error.message,checkedAt:now(),results:[]};}
|
|
67
73
|
const temporary=`${file}.${process.pid}.tmp`;fs.writeFileSync(temporary,JSON.stringify(report,null,2));fs.renameSync(temporary,file);
|
|
68
74
|
return report;
|
|
@@ -22,6 +22,47 @@ const isLink = file => { try { return fs.lstatSync(file).isSymbolicLink(); } cat
|
|
|
22
22
|
export const brainUpdateLockPath = owner => path.join(os.tmpdir(),
|
|
23
23
|
`total-recall-update-${createHash('sha256').update(owner).digest('hex')}.lock`);
|
|
24
24
|
|
|
25
|
+
const pause = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
26
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Take a per-owner update lock. The daemon's hourly `update --automatic` and a
|
|
30
|
+
* manual `update --all-brains` take the same locks, and a plain exclusive create
|
|
31
|
+
* failed the manual run outright with EEXIST. A lock whose process is gone is
|
|
32
|
+
* reclaimed; a live holder is waited for, up to `waitMs`.
|
|
33
|
+
*/
|
|
34
|
+
export function acquireBrainUpdateLock(lock, { waitMs = 600000, pollMs = 1000, isAlive = alive, sleep = pause } = {}) {
|
|
35
|
+
const deadline = Date.now() + waitMs;
|
|
36
|
+
for (;;) {
|
|
37
|
+
try {
|
|
38
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
39
|
+
fs.writeFileSync(fd, String(process.pid));
|
|
40
|
+
return fd;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code !== 'EEXIST') throw error;
|
|
43
|
+
}
|
|
44
|
+
let holder, age;
|
|
45
|
+
try {
|
|
46
|
+
holder = Number.parseInt(fs.readFileSync(lock, 'utf8').trim(), 10);
|
|
47
|
+
age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code === 'ENOENT') continue; // released between the create and the read
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
// A creator writes its pid right after the exclusive create; an empty lock
|
|
53
|
+
// younger than a few seconds is still being taken, not abandoned. Content
|
|
54
|
+
// that is not a pid names an unknown owner and is respected.
|
|
55
|
+
const content = holder ? 'pid' : fs.statSync(lock).size ? 'unknown' : 'empty';
|
|
56
|
+
const stale = content === 'pid' ? !isAlive(holder) : content === 'empty' && age > 5000;
|
|
57
|
+
if (stale) {
|
|
58
|
+
try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (Date.now() >= deadline) throw new Error(`Another update (${holder ? `pid ${holder}` : 'unknown owner'}) still holds ${lock}`);
|
|
62
|
+
sleep(pollMs);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export function installedBrainPackage(root, key) {
|
|
26
67
|
try {
|
|
27
68
|
const paths = createRequire(path.join(root, 'package.json')).resolve.paths(key) || [];
|
|
@@ -44,7 +85,14 @@ export function discoverBrainConsumers(roots, { onError } = {}) {
|
|
|
44
85
|
let pkg;
|
|
45
86
|
try { pkg = read(file); }
|
|
46
87
|
catch (error) { if (onError) { onError(real, error); return; } throw error; }
|
|
47
|
-
|
|
88
|
+
// A plugin copied into a shared plugin folder is installed without its dev
|
|
89
|
+
// dependencies, on purpose: full dev trees there filled a disk. Counting its
|
|
90
|
+
// devDependencies made every such plugin a permanently failing consumer, and
|
|
91
|
+
// "fixing" it would mean installing exactly what must never be installed.
|
|
92
|
+
const sharedPluginCopy = fs.existsSync(path.join(real, 'plugin.json'))
|
|
93
|
+
&& real.split(path.sep).join('/').includes('/skills/total-recall/plugins/');
|
|
94
|
+
const deps = sections.filter(section => !(sharedPluginCopy && section === 'devDependencies'))
|
|
95
|
+
.flatMap(section => Object.entries(pkg[section] || {})
|
|
48
96
|
.filter(([key]) => BRAIN_PACKAGES.includes(canonical(key)) && canonical(key) !== pkg.name)
|
|
49
97
|
.map(([key, declared]) => ({ section, key, package: canonical(key), declared,
|
|
50
98
|
...installedBrainPackage(real, key) })));
|
|
@@ -168,8 +216,7 @@ export async function updateAllBrains(opts = {}) {
|
|
|
168
216
|
isLink(path.join(consumer.root, 'node_modules')) || consumer.deps.some(dep =>
|
|
169
217
|
dep.version !== versions[dep.package] || !dep.file?.startsWith(path.join(group.owner, 'node_modules') + path.sep)));
|
|
170
218
|
try {
|
|
171
|
-
lockFd =
|
|
172
|
-
fs.writeFileSync(lockFd, String(process.pid));
|
|
219
|
+
lockFd = acquireBrainUpdateLock(lock, opts.lockWaitMs === undefined ? {} : { waitMs: opts.lockWaitMs });
|
|
173
220
|
for (const name of ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml']) {
|
|
174
221
|
const file = path.join(group.owner, name);
|
|
175
222
|
saved.set(file, fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null);
|
|
@@ -198,11 +245,22 @@ export async function updateAllBrains(opts = {}) {
|
|
|
198
245
|
if (install.status !== 0) throw new Error(install.error?.message || install.stderr?.trim().slice(-1500) || `Install exited ${install.status}`);
|
|
199
246
|
for (const { consumer, row } of group.consumers) {
|
|
200
247
|
row.packages = row.packages.map(dep => ({ ...dep, installed: installedBrainPackage(consumer.root, dep.key).version }));
|
|
248
|
+
// A consumer may legitimately resolve the package from its own node_modules — nested plugin
|
|
249
|
+
// directories do exactly that. Requiring the owner's hoisted copy reported a correct install
|
|
250
|
+
// as a failure and rolled the whole batch back, which is why 30 consumers never updated.
|
|
251
|
+
const allowedRoots = [
|
|
252
|
+
path.join(group.owner, 'node_modules') + path.sep,
|
|
253
|
+
path.join(consumer.root, 'node_modules') + path.sep,
|
|
254
|
+
];
|
|
255
|
+
const placed = (file) => Boolean(file) && allowedRoots.some((root) => file.startsWith(root));
|
|
201
256
|
const bad = consumer.deps.find(dep => {
|
|
202
257
|
const actual = installedBrainPackage(consumer.root, dep.key);
|
|
203
|
-
return actual.version !== versions[dep.package] || !actual.file
|
|
258
|
+
return actual.version !== versions[dep.package] || !placed(actual.file);
|
|
204
259
|
});
|
|
205
|
-
|
|
260
|
+
// A link is only a fault when it escapes every legitimate root; a self-contained one is fine.
|
|
261
|
+
const linkPath = path.join(consumer.root, 'node_modules');
|
|
262
|
+
const linked = isLink(linkPath) && !placed(path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath)));
|
|
263
|
+
if (bad || linked) throw new Error(`Installed release verification failed: ${consumer.root}${bad ? ` (${bad.key})` : ' (shared link)'}`);
|
|
206
264
|
row.status = 'updated';
|
|
207
265
|
}
|
|
208
266
|
} catch (error) {
|
|
@@ -6,6 +6,24 @@ import {BRAIN_PACKAGES} from './brain-updates.mjs';
|
|
|
6
6
|
import {comparePackageVersions} from './package-version.mjs';
|
|
7
7
|
import {updateSourceBrainPackage} from './source-brain-updates.mjs';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A global link that points at a source checkout of `name` under another
|
|
11
|
+
* directory name (the package was once published as `total-recall`). Missing it
|
|
12
|
+
* made the update `npm install -g` over the bin and replace the operator's
|
|
13
|
+
* source checkout with a registry copy.
|
|
14
|
+
*/
|
|
15
|
+
export function findLinkedSourceCheckout(root,name){
|
|
16
|
+
let entries=[];try{entries=fs.readdirSync(root,{withFileTypes:true});}catch{return null;}
|
|
17
|
+
for(const entry of entries){
|
|
18
|
+
if(!entry.isSymbolicLink())continue;
|
|
19
|
+
try{
|
|
20
|
+
const dir=fs.realpathSync(path.join(root,entry.name));
|
|
21
|
+
if(JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8')).name===name&&fs.existsSync(path.join(dir,'.git')))return dir;
|
|
22
|
+
}catch{}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
export function updateGlobalBrainPackages({versions,exec=spawnSync,sourceUpdate=updateSourceBrainPackage}={}) {
|
|
10
28
|
const prefix=exec('npm',['root','--global'],{encoding:'utf8',timeout:15000});
|
|
11
29
|
if(prefix.status!==0)return {failed:1,results:[{status:'failed',error:'Cannot resolve global npm root'}]};
|
|
@@ -13,7 +31,9 @@ export function updateGlobalBrainPackages({versions,exec=spawnSync,sourceUpdate=
|
|
|
13
31
|
const results=[];
|
|
14
32
|
const install=[];
|
|
15
33
|
for(const name of BRAIN_PACKAGES){
|
|
16
|
-
|
|
34
|
+
let directory=path.join(root,name);
|
|
35
|
+
if(!fs.existsSync(directory))directory=findLinkedSourceCheckout(root,name)||directory;
|
|
36
|
+
const file=path.join(directory,'package.json');
|
|
17
37
|
let installed;try{installed=JSON.parse(fs.readFileSync(file,'utf8')).version;}catch{}
|
|
18
38
|
const row={package:name,installed:installed || null,latest:versions[name],status:'up_to_date'};results.push(row);
|
|
19
39
|
if(installed===versions[name])continue;
|
|
@@ -15,14 +15,17 @@ export async function updateMeshBrainPackages({version,versions,listNodes,exec,n
|
|
|
15
15
|
// Read the durable report first, then launch only when stale or failed.
|
|
16
16
|
const observed=await exec(node.ip,'cd "$HOME" && total-recall update --status --json',{timeoutMs:20000});
|
|
17
17
|
let report;try{report=JSON.parse(observed.stdout).last;}catch{}
|
|
18
|
-
const
|
|
18
|
+
const localFailed=report?(report.local_failed ?? Math.max(0,(report.failed || 0)-(report.mesh?.failed || 0))):null;
|
|
19
|
+
const current=observed.success && report && localFailed===0 && now()>=report.checkedAt && now()-report.checkedAt<2*60*60*1000 && report.versions?.['total-recall-brain']===version && (!versions?.['@gregiteen/ssss-cli'] || report.versions?.['@gregiteen/ssss-cli']===versions['@gregiteen/ssss-cli']);
|
|
19
20
|
if(current){row.status='verified';row.report=report;continue;}
|
|
20
21
|
// npm exec pins the verified release so an old installed CLI can bootstrap
|
|
21
22
|
// this capability. The remote command owns its lock, verification and report.
|
|
22
23
|
const command=`cd "$HOME" && nohup npm exec --yes --package=total-recall-brain@${version} -- total-recall update --automatic --local-only --json </dev/null >/tmp/total-recall-automatic-update.log 2>&1 &`;
|
|
23
24
|
const launched=await exec(node.ip,command,{timeoutMs:20000});
|
|
24
25
|
row.status=launched.success?'dispatched':'failed';
|
|
25
|
-
row.error
|
|
26
|
+
row.error=!launched.success?launched.stderr || 'Mesh maintenance dispatch failed'
|
|
27
|
+
:localFailed?`Peer reports ${localFailed} local maintenance failure(s); re-dispatched`
|
|
28
|
+
:'Awaiting independently verified remote report';
|
|
26
29
|
}catch(error){row.status='failed';row.error=error.message;}
|
|
27
30
|
}
|
|
28
31
|
// A dispatched process is not a verified update. Keep it visible for retries.
|
|
@@ -593,9 +593,9 @@ export async function queryHarnessUsage() {
|
|
|
593
593
|
status: 'not_installed',
|
|
594
594
|
identity: '—',
|
|
595
595
|
plan: '—',
|
|
596
|
-
rolling5hRemainingPct: '
|
|
597
|
-
weeklyRemainingPct: '
|
|
598
|
-
quotaRemaining: '
|
|
596
|
+
rolling5hRemainingPct: '—',
|
|
597
|
+
weeklyRemainingPct: '—',
|
|
598
|
+
quotaRemaining: 'not installed'
|
|
599
599
|
});
|
|
600
600
|
continue;
|
|
601
601
|
}
|
|
@@ -606,9 +606,13 @@ export async function queryHarnessUsage() {
|
|
|
606
606
|
|
|
607
607
|
let identity = 'Detected local install';
|
|
608
608
|
let plan = 'Standard';
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
609
|
+
// Never invent a quota. These fields held hardcoded literals ('100%', '98%', '85%') that read
|
|
610
|
+
// like measurements but came from no source at all — no provider usage endpoint was called, so
|
|
611
|
+
// the table reported fictional headroom for every harness. A figure appears here only when a
|
|
612
|
+
// real source produced it; otherwise the value is honestly unknown.
|
|
613
|
+
let quotaRemaining = 'unknown — no usage endpoint integrated';
|
|
614
|
+
let rolling5hRemainingPct = '—';
|
|
615
|
+
let weeklyRemainingPct = '—';
|
|
612
616
|
let isAuthed = h.authed;
|
|
613
617
|
let authRoute = h.authRoute;
|
|
614
618
|
|
|
@@ -620,9 +624,6 @@ export async function queryHarnessUsage() {
|
|
|
620
624
|
if (acc.active) identity = acc.active;
|
|
621
625
|
}
|
|
622
626
|
plan = 'Google AI Ultra / Pro';
|
|
623
|
-
rolling5hRemainingPct = '100%';
|
|
624
|
-
weeklyRemainingPct = '98%';
|
|
625
|
-
quotaRemaining = '5h window: 100% | Weekly compute: 98%';
|
|
626
627
|
} catch {}
|
|
627
628
|
} else if (h.id === 'claude') {
|
|
628
629
|
try {
|
|
@@ -632,9 +633,6 @@ export async function queryHarnessUsage() {
|
|
|
632
633
|
if (cData.userID) identity = `User: ${cData.userID.slice(0, 8)}...`;
|
|
633
634
|
plan = cData.opusProMigrationComplete ? 'Claude Pro / Max (Opus enabled)' : 'Claude Pro';
|
|
634
635
|
}
|
|
635
|
-
rolling5hRemainingPct = '85%';
|
|
636
|
-
weeklyRemainingPct = '92%';
|
|
637
|
-
quotaRemaining = '5h window: 85% remaining | Weekly cap: 92% remaining';
|
|
638
636
|
} catch {}
|
|
639
637
|
} else if (h.id === 'codex') {
|
|
640
638
|
try {
|
|
@@ -659,15 +657,7 @@ export async function queryHarnessUsage() {
|
|
|
659
657
|
}
|
|
660
658
|
plan = 'ChatGPT Subscription (GPT-4o/Codex)';
|
|
661
659
|
}
|
|
662
|
-
if (isAuthed)
|
|
663
|
-
rolling5hRemainingPct = '94%';
|
|
664
|
-
weeklyRemainingPct = '96%';
|
|
665
|
-
quotaRemaining = '5h reasoning: 94% remaining | Weekly quota: 96% remaining';
|
|
666
|
-
} else {
|
|
667
|
-
rolling5hRemainingPct = '0% (expired)';
|
|
668
|
-
weeklyRemainingPct = '0% (expired)';
|
|
669
|
-
quotaRemaining = 'Authentication expired';
|
|
670
|
-
}
|
|
660
|
+
if (!isAuthed) quotaRemaining = 'unavailable — authentication expired';
|
|
671
661
|
} catch {}
|
|
672
662
|
} else if (h.id === 'grok') {
|
|
673
663
|
try {
|
|
@@ -685,15 +675,7 @@ export async function queryHarnessUsage() {
|
|
|
685
675
|
}
|
|
686
676
|
plan = 'Grok Build Tier';
|
|
687
677
|
}
|
|
688
|
-
if (isAuthed)
|
|
689
|
-
rolling5hRemainingPct = '100%';
|
|
690
|
-
weeklyRemainingPct = '100%';
|
|
691
|
-
quotaRemaining = '5h rate window: 100% | Weekly allowance: 100%';
|
|
692
|
-
} else {
|
|
693
|
-
rolling5hRemainingPct = '0% (expired)';
|
|
694
|
-
weeklyRemainingPct = '0% (expired)';
|
|
695
|
-
quotaRemaining = 'Token expired (refresh needed)';
|
|
696
|
-
}
|
|
678
|
+
if (!isAuthed) quotaRemaining = 'unavailable — token expired (refresh needed)';
|
|
697
679
|
} catch {}
|
|
698
680
|
} else if (h.id === 'gemini') {
|
|
699
681
|
try {
|
|
@@ -703,9 +685,6 @@ export async function queryHarnessUsage() {
|
|
|
703
685
|
if (acc.active) identity = acc.active;
|
|
704
686
|
}
|
|
705
687
|
plan = 'Google AI API / Subscription';
|
|
706
|
-
rolling5hRemainingPct = '100%';
|
|
707
|
-
weeklyRemainingPct = '100%';
|
|
708
|
-
quotaRemaining = 'Standard rate limits active';
|
|
709
688
|
} catch {}
|
|
710
689
|
} else if (h.id === 'ollama') {
|
|
711
690
|
identity = 'Local Machine';
|
|
@@ -716,9 +695,7 @@ export async function queryHarnessUsage() {
|
|
|
716
695
|
} else if (h.id === 'hermes') {
|
|
717
696
|
identity = 'Hermes Supervisor';
|
|
718
697
|
plan = 'Autonomous Agent Runtime';
|
|
719
|
-
|
|
720
|
-
weeklyRemainingPct = '100%';
|
|
721
|
-
quotaRemaining = 'Active broker & gateway';
|
|
698
|
+
quotaRemaining = 'local runtime — no metered quota';
|
|
722
699
|
} else if (h.id === 'dsh') {
|
|
723
700
|
let model = 'deepseek/deepseek-v4.1-flash';
|
|
724
701
|
try {
|
|
@@ -729,9 +706,9 @@ export async function queryHarnessUsage() {
|
|
|
729
706
|
} catch {}
|
|
730
707
|
identity = model;
|
|
731
708
|
plan = 'DeepSeek Dedicated Harness';
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
quotaRemaining = '
|
|
709
|
+
// dsh bills through OpenRouter metered credit and publishes no quota figures of its own, so
|
|
710
|
+
// none are shown. Point the reader at the one place the real number exists.
|
|
711
|
+
quotaRemaining = 'metered — see OpenRouter credits';
|
|
735
712
|
} else if (h.id === 'openclaw') {
|
|
736
713
|
let channelsStr = 'telegram, qqbot';
|
|
737
714
|
try {
|
|
@@ -745,8 +722,6 @@ export async function queryHarnessUsage() {
|
|
|
745
722
|
}
|
|
746
723
|
} catch {}
|
|
747
724
|
plan = 'Multi-Channel Gateway';
|
|
748
|
-
rolling5hRemainingPct = '100%';
|
|
749
|
-
weeklyRemainingPct = '100%';
|
|
750
725
|
quotaRemaining = `Channels: ${channelsStr}`;
|
|
751
726
|
}
|
|
752
727
|
|
|
@@ -56,7 +56,12 @@ export function runPluginCommand(plugin, options = {}) {
|
|
|
56
56
|
if (!pluginExecutionEnabled(plugin, { projectRoot: cwd }) && !['status', 'health', 'jobs', '--help', '-h', 'help'].includes(operation)) {
|
|
57
57
|
return Promise.reject(new Error(`Plugin ${plugin.id} is inactive; run total-recall plugin on ${plugin.id}`));
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
// Manifests declare this as `timeoutMs`; the runner only read `timeout_ms`, so every declared
|
|
60
|
+
// timeout was ignored and each command fell back to DEFAULT_TIMEOUT_MS (30s). That killed work
|
|
61
|
+
// that legitimately takes longer — `mesh nodes` and `tr-transcribe transcribe` (model load) both
|
|
62
|
+
// died at 30s while their manifests asked for far more. Accept both spellings.
|
|
63
|
+
const declaration = plugin?.manifest?.cli?.subcommands?.find(item => item.name === operation);
|
|
64
|
+
const declaredTimeout = declaration?.timeout_ms ?? declaration?.timeoutMs;
|
|
60
65
|
const timeoutMs = requestedTimeoutMs ?? declaredTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
61
66
|
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 600000) return Promise.reject(new Error('Plugin timeout must be 1..600000 ms'));
|
|
62
67
|
const handlerRel = plugin?.manifest?.cli?.handler;
|
|
@@ -417,6 +417,67 @@ export async function installPluginDependencies(dest, id) {
|
|
|
417
417
|
return { installed: true, packages };
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
+
const REPLACEMENT_NAME = /^(.+)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
|
421
|
+
|
|
422
|
+
/** Where `install --replace` keeps the copy it replaced, beside a plugins directory. */
|
|
423
|
+
export function replacementArchiveDir(pluginsDir) {
|
|
424
|
+
return path.join(path.dirname(pluginsDir), 'memory-derived', 'plugin-replacements');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function directoryBytes(dir) {
|
|
428
|
+
let total = 0;
|
|
429
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
430
|
+
const full = path.join(dir, entry.name);
|
|
431
|
+
if (entry.isDirectory()) total += directoryBytes(full);
|
|
432
|
+
else if (entry.isFile()) total += fs.statSync(full).size;
|
|
433
|
+
}
|
|
434
|
+
return total;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Bound the copies `install --replace` keeps. Every replacement archives the
|
|
439
|
+
* previous install, dependencies included, and nothing removed them, so the
|
|
440
|
+
* hourly automatic plugin update grew the archive without limit: 73 copies
|
|
441
|
+
* filled a laptop disk. Keep the newest `keep` copies of each plugin (or of
|
|
442
|
+
* `id` only), without node_modules, which a rollback reinstalls. A rename
|
|
443
|
+
* keeps a directory's old mtime, so the copy just archived is named in
|
|
444
|
+
* `prefer` and always counts as the newest.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} archiveDir
|
|
447
|
+
* @param {{ id?: string, keep?: number, apply?: boolean, prefer?: string }} [opts]
|
|
448
|
+
*/
|
|
449
|
+
export function pruneReplacementArchives(archiveDir, { id, keep = 1, apply = true, prefer } = {}) {
|
|
450
|
+
if (!fs.existsSync(archiveDir)) return { archiveDir, kept: [], removed: [], stripped: [], bytesFreed: 0 };
|
|
451
|
+
const byId = new Map();
|
|
452
|
+
for (const name of fs.readdirSync(archiveDir)) {
|
|
453
|
+
const m = REPLACEMENT_NAME.exec(name);
|
|
454
|
+
const full = path.join(archiveDir, name);
|
|
455
|
+
if (!m || (id && m[1] !== id) || !fs.statSync(full).isDirectory()) continue;
|
|
456
|
+
(byId.get(m[1]) || byId.set(m[1], []).get(m[1])).push({ name, full, mtime: fs.statSync(full).mtimeMs });
|
|
457
|
+
}
|
|
458
|
+
const kept = [], removed = [], stripped = [];
|
|
459
|
+
let bytesFreed = 0;
|
|
460
|
+
for (const copies of byId.values()) {
|
|
461
|
+
copies.sort((a, b) => (b.name === prefer) - (a.name === prefer) || b.mtime - a.mtime);
|
|
462
|
+
copies.forEach((copy, index) => {
|
|
463
|
+
if (index >= keep) {
|
|
464
|
+
bytesFreed += directoryBytes(copy.full);
|
|
465
|
+
if (apply) fs.rmSync(copy.full, { recursive: true, force: true });
|
|
466
|
+
removed.push(copy.name);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
kept.push(copy.name);
|
|
470
|
+
const modules = path.join(copy.full, 'node_modules');
|
|
471
|
+
if (fs.existsSync(modules)) {
|
|
472
|
+
bytesFreed += directoryBytes(modules);
|
|
473
|
+
if (apply) fs.rmSync(modules, { recursive: true, force: true });
|
|
474
|
+
stripped.push(copy.name);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return { archiveDir, kept, removed, stripped, bytesFreed };
|
|
479
|
+
}
|
|
480
|
+
|
|
420
481
|
/**
|
|
421
482
|
* Install a plugin.
|
|
422
483
|
* @param {string} source bundled id | public share URL | `peer:<host>/<id>` | git URL | local path
|
|
@@ -439,7 +500,7 @@ export async function installPlugin(source, options = {}) {
|
|
|
439
500
|
const prepareDestination=id=>{
|
|
440
501
|
const target=path.join(pluginsDir,id);
|
|
441
502
|
if(!options.replace || !fs.existsSync(target))return assertNotInstalled(id,pluginsDir);
|
|
442
|
-
const archive=path.join(
|
|
503
|
+
const archive=path.join(replacementArchiveDir(pluginsDir),`${id}-${crypto.randomUUID()}`);
|
|
443
504
|
fs.mkdirSync(path.dirname(archive),{recursive:true});
|
|
444
505
|
fs.renameSync(target,archive);previousDirectory={target,archive};
|
|
445
506
|
};
|
|
@@ -524,6 +585,11 @@ export async function installPlugin(source, options = {}) {
|
|
|
524
585
|
throw new Error(`Plugin files were written but its record could not be: ${err.message}`);
|
|
525
586
|
}
|
|
526
587
|
|
|
588
|
+
// The install succeeded: keep only this replacement's copy of the plugin, without dependencies.
|
|
589
|
+
if (previousDirectory) {
|
|
590
|
+
pruneReplacementArchives(replacementArchiveDir(pluginsDir), { id: manifest.id, keep: 1, prefer: path.basename(previousDirectory.archive) });
|
|
591
|
+
}
|
|
592
|
+
|
|
527
593
|
// A batch of installs (automatic updates) compiles once after the batch instead.
|
|
528
594
|
const compilation = options.recompile === false ? { skipped: true } : await recompileAfterPluginMutation(projectRoot, isGlobal);
|
|
529
595
|
return {
|
|
@@ -50,7 +50,7 @@ export async function checkPlugin(plugin, { gitHead = remoteGitHead, peerHash =
|
|
|
50
50
|
return { status: 'pinned', ref: source.ref };
|
|
51
51
|
case 'bundled': {
|
|
52
52
|
const shipped = bundled().find(p => p.id === id);
|
|
53
|
-
if (!shipped) return { status: 'failed', error: `Bundled plugin '${id}' is no longer shipped in this package` };
|
|
53
|
+
if (!shipped) return { status: 'failed', error: `Bundled plugin '${id}' is no longer shipped in this package; reinstall it from its git repository with: total-recall plugin install <git-url> --global --replace` };
|
|
54
54
|
return hashPluginDir(shipped.dir).sha256 === plugin.installed_sha256 ? { status: 'current' } : { status: 'behind', install: id };
|
|
55
55
|
}
|
|
56
56
|
case 'git': {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Existing daemon maintenance verifies explicitly enrolled owner skill packages. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { loadRegistry } from './skills-registry.mjs';
|
|
5
|
+
import { runBounded } from './startup-health.mjs';
|
|
6
|
+
|
|
7
|
+
export async function maintainPortableSkills({ brainDir, apply = true, run = runBounded } = {}) {
|
|
8
|
+
const entry = loadRegistry(brainDir).skills.ssss;
|
|
9
|
+
if (!entry || !entry.layered || entry.source_type !== 'portable-owner-package') return { skipped: true, reason: 'no-enrolled-portable-source', failed: 0, results: [] };
|
|
10
|
+
const script = path.join(entry.source_path, 'core/scripts/maintain.mjs');
|
|
11
|
+
if (!fs.existsSync(script)) return { failed: 1, results: [], error: 'Registered portable source maintenance helper is missing' };
|
|
12
|
+
const result = await run(process.execPath, [script, '--source', entry.source_path, ...(apply ? ['--apply'] : [])], { timeoutMs: 5 * 60 * 1000, maxBytes: 8 * 1024 * 1024 });
|
|
13
|
+
try {
|
|
14
|
+
const report = JSON.parse(result.output);
|
|
15
|
+
return { ...report, failed: Math.max(report.failed || 0, result.code === 0 ? 0 : 1) };
|
|
16
|
+
} catch {
|
|
17
|
+
return { failed: 1, results: [], error: result.reason || result.output || 'Portable skill verification returned no report' };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -152,6 +152,13 @@ export function auditProjectPluginCopies(projectRoot, { sharedDir = globalPlugin
|
|
|
152
152
|
row.sourceVersion = row.sourceDir ? version(row.sourceDir) : null;
|
|
153
153
|
row.sharedVersion = row.sharedDir ? version(row.sharedDir) : null;
|
|
154
154
|
row.edited = row.sourceDir ? !sameTree(copyDir, row.sourceDir) : null;
|
|
155
|
+
// A copy declaring an older release than the shared install cannot be a deliberate local edit —
|
|
156
|
+
// it predates the shared plugin. It is a leftover from the release that vendored the defaults into
|
|
157
|
+
// every brain, and because discovery prefers a brain's own copy it SHADOWS the shared install and
|
|
158
|
+
// breaks the repository: a stale auth manifest fails validation, so every total-recall command in
|
|
159
|
+
// that repo dies with "Required plugin auth is missing or invalid". Same staleness rule the updater
|
|
160
|
+
// already applies when it refreshes a vendored copy.
|
|
161
|
+
row.stale = Boolean(row.version && row.sharedVersion && comparePackageVersions(row.version, row.sharedVersion) < 0);
|
|
155
162
|
copies.push(row);
|
|
156
163
|
}
|
|
157
164
|
return { copies, count: copies.length, sourceDir };
|
|
@@ -456,6 +456,11 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
456
456
|
if (layered && fs.existsSync(destDir) && fs.lstatSync(destDir).isSymbolicLink()) {
|
|
457
457
|
throw new Error(`Refusing layered deploy into symlink: ${destDir}`);
|
|
458
458
|
}
|
|
459
|
+
if (opts.dryRun) return { success: true, dryRun: true, skillId: resolved.id, destDir,
|
|
460
|
+
sourcePath: resolved.sourcePath, layered, existed, routeCore: Boolean(opts.routeCore), actions: [
|
|
461
|
+
{ op: 'replace-managed', from: layered && existed ? path.join(resolved.sourcePath, 'core') : resolved.sourcePath, to: layered && existed ? path.join(destDir, 'core') : destDir },
|
|
462
|
+
...(opts.routeCore && layered ? [{ op: 'route-core', path: path.join(destDir, 'SKILL.md') }] : []),
|
|
463
|
+
] };
|
|
459
464
|
const replacement = layered && existed
|
|
460
465
|
? replaceSkillDir(path.join(resolved.sourcePath, 'core'), path.join(destDir, 'core'), {
|
|
461
466
|
requiredFile: null,
|
|
@@ -463,6 +468,14 @@ export function deploySkill(brainDir, skillIdOrPath, opts = {}) {
|
|
|
463
468
|
})
|
|
464
469
|
: replaceSkillDir(resolved.sourcePath, destDir, { managedFiles: priorInstall?.managed_files || [] });
|
|
465
470
|
|
|
471
|
+
if (opts.routeCore && layered) {
|
|
472
|
+
const wrapper = path.join(destDir, 'SKILL.md');
|
|
473
|
+
const current = fs.readFileSync(wrapper, 'utf8');
|
|
474
|
+
const marker = '<!-- total-recall:managed-core-route:v1 -->';
|
|
475
|
+
if (!current.includes(marker)) fs.writeFileSync(wrapper, current.replace(/\s*$/, '') +
|
|
476
|
+
`\n\n${marker}\nRead the [maintained portable core](core/SKILL.md) for current protocol contracts; preserve repository-specific instructions above.\n`);
|
|
477
|
+
}
|
|
478
|
+
|
|
466
479
|
let adaptResult = { adapted: false };
|
|
467
480
|
if (opts.adapt && (!layered || !existed)) {
|
|
468
481
|
const openwikiDir =
|
|
@@ -536,7 +549,7 @@ function installDetail(entry, inst) {
|
|
|
536
549
|
const liveHash = exists ? hashSkillContent(inst.path) : null;
|
|
537
550
|
const liveCoreHash = layered && exists ? hashSkillLayer(inst.path, 'core') : null;
|
|
538
551
|
const liveRepoHash = layered && exists ? hashSkillLayer(inst.path, 'repo') : null;
|
|
539
|
-
const drift = layered
|
|
552
|
+
const drift = !exists ? true : layered
|
|
540
553
|
? liveCoreHash !== (entry?.core_hash || inst.core_hash)
|
|
541
554
|
: legacyUnderLayered
|
|
542
555
|
? false
|
|
@@ -548,7 +561,9 @@ function installDetail(entry, inst) {
|
|
|
548
561
|
return {
|
|
549
562
|
...inst,
|
|
550
563
|
exists,
|
|
564
|
+
required: !inst.obsolete,
|
|
551
565
|
live_hash: liveHash,
|
|
566
|
+
verification: !exists ? 'missing' : legacyUnderLayered ? 'not_adopted' : drift ? 'drifted' : 'verified',
|
|
552
567
|
...(layered ? {
|
|
553
568
|
core_hash: liveCoreHash,
|
|
554
569
|
repo_hash: liveRepoHash,
|
|
@@ -576,7 +591,7 @@ export function skillStatus(brainDir, skillId) {
|
|
|
576
591
|
} catch (err) {
|
|
577
592
|
// One unreadable install (a symlinked layer, a moved repo) must not hide
|
|
578
593
|
// the status of every other install.
|
|
579
|
-
return { ...inst, exists: fs.existsSync(inst.path), error: err.message, drift:
|
|
594
|
+
return { ...inst, exists: fs.existsSync(inst.path), error: err.message, drift: true, verification: 'error' };
|
|
580
595
|
}
|
|
581
596
|
});
|
|
582
597
|
|
|
@@ -593,7 +608,11 @@ export function skillStatus(brainDir, skillId) {
|
|
|
593
608
|
localPath,
|
|
594
609
|
installs: installDetails,
|
|
595
610
|
install_count: installDetails.length,
|
|
596
|
-
any_drift: installDetails.some((i) => i.drift),
|
|
611
|
+
any_drift: installDetails.some((i) => !i.obsolete && i.drift),
|
|
612
|
+
verified: Boolean(entry && localPath) && installDetails.every((i) => i.obsolete || (i.verification === 'verified' && (!i.contract || i.contract.ok))),
|
|
613
|
+
missing_count: installDetails.filter((i) => !i.obsolete && !i.exists).length,
|
|
614
|
+
obsolete_count: installDetails.filter((i) => i.obsolete).length,
|
|
615
|
+
error_count: installDetails.filter((i) => i.error).length,
|
|
597
616
|
any_contract_failure: installDetails.some((i) => i.contract && !i.contract.ok),
|
|
598
617
|
};
|
|
599
618
|
}
|
|
@@ -1269,6 +1288,17 @@ export function syncSkillTwoWay(brainDir, skillId, opts = {}) {
|
|
|
1269
1288
|
}
|
|
1270
1289
|
|
|
1271
1290
|
const { entry, locations } = collectSkillLocations(brainDir, skillId);
|
|
1291
|
+
const missingInstalls = loadRegistry(brainDir).installs.filter((inst) =>
|
|
1292
|
+
inst.skill_id === skillId && !inst.obsolete && !fs.existsSync(path.join(inst.path, 'SKILL.md')),
|
|
1293
|
+
);
|
|
1294
|
+
if (missingInstalls.length) {
|
|
1295
|
+
return {
|
|
1296
|
+
skillId, in_sync: false, dryRun,
|
|
1297
|
+
error: 'Recorded skill destinations are missing; reconcile existing repositories and deploy explicitly before synchronization',
|
|
1298
|
+
missing: missingInstalls.map((inst) => ({ path: inst.path, repo: inst.repo, repository_exists: Boolean(inst.repo && fs.existsSync(inst.repo)) })),
|
|
1299
|
+
locations: locations.length, actions: [],
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1272
1302
|
if (!locations.length) {
|
|
1273
1303
|
return { skillId, skipped: true, reason: 'no live copies found' };
|
|
1274
1304
|
}
|