ruvnet-brain 4.7.19 → 4.7.21
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/README.md +2 -2
- package/bin/install.mjs +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +8 -16
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +105 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +164 -47
- package/scripts/job-heartbeat.sh +145 -13
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +30 -21
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +549 -0
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +71 -26
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// One existing-install owner. Does not install schedulers or remove plugin generations.
|
|
3
|
+
// Over the 500-line guideline in 4.7.21 on purpose: a split needs a new module in EXECUTION_MODULES, which older registrations
|
|
4
|
+
// cannot stage. The split into a new closure module is planned for 4.7.22, with the runner-changed re-registration work.
|
|
3
5
|
import fs from 'node:fs';
|
|
4
6
|
import path from 'node:path';
|
|
5
7
|
import os from 'node:os';
|
|
@@ -11,6 +13,7 @@ import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.
|
|
|
11
13
|
import { maintenance } from './developer-update-maintenance.mjs';
|
|
12
14
|
import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
|
|
13
15
|
import { pinnedClaudeTarget, verifyClaudeArtifact } from './plugin-artifact-proof.mjs';
|
|
16
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
14
17
|
export { compare, selectTag, sharedLockStatus };
|
|
15
18
|
const HOME = os.homedir();
|
|
16
19
|
const PREFIX = path.join(HOME, '.npm-global');
|
|
@@ -236,13 +239,16 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
236
239
|
const writable = before.filter(p => p.scope !== 'managed' && !(unattended && ['project', 'local'].includes(p.scope))), flags = pluginFlags(writable, home);
|
|
237
240
|
const provider = writable.length ? claudeProviderCommand(locate('claude'), { prefix, run }) : { command: locate('claude') };
|
|
238
241
|
const claude = provider.command;
|
|
239
|
-
|
|
242
|
+
// No Claude CLI on this PATH (desktop-app, VS Code, nvm or ~/.claude/local installs): nothing can update these plugins
|
|
243
|
+
// here, so each is preserved and reported UNSUPPORTED instead of ending the night (AuditC #1).
|
|
244
|
+
const noClaude = writable.length > 0 && !claude;
|
|
240
245
|
const steps = [], artifactCache = new Map();
|
|
241
|
-
const markets = [...new Set(writable.map(p => p.id.split('@').at(-1)))];
|
|
246
|
+
const markets = noClaude ? [] : [...new Set(writable.map(p => p.id.split('@').at(-1)))];
|
|
242
247
|
if (!dryRun) for (const name of markets) run(claude, ['plugin', 'marketplace', 'update', name]);
|
|
243
248
|
for (const p of before) {
|
|
244
|
-
if (!writable.includes(p)) {
|
|
245
|
-
const reason =
|
|
249
|
+
if (!writable.includes(p) || noClaude) {
|
|
250
|
+
const reason = noClaude && writable.includes(p) ? 'no existing Claude command on this PATH; installed plugin preserved'
|
|
251
|
+
: p.scope === 'managed' ? 'managed scope belongs to its administrator' : 'unattended project settings unverified; original scope preserved';
|
|
246
252
|
steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, state: 'UNSUPPORTED', reason });
|
|
247
253
|
notes.push(`plugin target unsupported; preserved: ${p.id} (${p.scope}): ${reason}`);
|
|
248
254
|
continue;
|
|
@@ -250,9 +256,17 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
250
256
|
const target = resolvePluginTarget(p, { home, run });
|
|
251
257
|
let artifactTarget = null, artifactProof = null;
|
|
252
258
|
if (target.supported && target.authority === 'commit' && target.proof === 'catalog-pinned-commit' && p.gitCommitSha?.toLowerCase() !== target.commit) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
259
|
+
try {
|
|
260
|
+
artifactTarget = await artifactProvider(target.source, target.commit, { cache: artifactCache });
|
|
261
|
+
artifactProof = verifyClaudeArtifact(artifactTarget, p.installPath);
|
|
262
|
+
target.version = artifactTarget.manifest.version || null;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// A rate-limited or oversized pinned-artifact proof leaves this plugin unproven, not the night failed (AuditC #16).
|
|
265
|
+
const reason = `pinned artifact unproven: ${error.message}`;
|
|
266
|
+
steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, target, state: 'UNSUPPORTED', reason });
|
|
267
|
+
notes.push(`plugin target unsupported; preserved: ${p.id}: ${reason}`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
256
270
|
}
|
|
257
271
|
let decision = p.scope === 'managed' ? { state: 'UNSUPPORTED', reason: 'managed scope belongs to its administrator' } : pluginUpdateDecision(artifactProof?.actualVersion ? { ...p, version: artifactProof.actualVersion } : p, target);
|
|
258
272
|
if (artifactProof?.ok && decision.state !== 'AHEAD') decision = { state: 'CURRENT', proof: 'pinned-claude-artifact', sourceCommitMatched: false, actualClaudeSourceMatched: true, providerMetadataDiscrepancy: { recordedCommit: p.gitCommitSha, pinnedCommit: target.commit } };
|
|
@@ -280,36 +294,92 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
|
|
|
280
294
|
const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
|
|
281
295
|
if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
|
|
282
296
|
const help = run(codex, ['plugin', '--help'], { timeout: 30_000 });
|
|
283
|
-
|
|
284
|
-
notes.push('Codex
|
|
297
|
+
// Tripwire, reported not thrown: a new Codex command must not end the night before the knowledge is checked (AuditC #17).
|
|
298
|
+
if (/^\s+update\s/m.test(help)) notes.push('Codex plugin update now exists and is unsupported until its scope-aware policy is reviewed; installed generations preserved');
|
|
299
|
+
else notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
|
|
285
300
|
}
|
|
286
301
|
if (JSON.stringify(flags) !== JSON.stringify(pluginFlags(writable, home))) throw Error('Codex plugin enablement changed');
|
|
287
302
|
return { before, after, steps, provider };
|
|
288
303
|
}
|
|
289
|
-
|
|
304
|
+
// The bundle download may take its 90-minute deadline (kb/download-retry.mjs) plus the apply; host-update.mjs's own
|
|
305
|
+
// knowledge limit and the SessionStart TTL sit above this, so a kill never lands mid-transaction one level up.
|
|
306
|
+
export const INSTALL_UPDATE_TIMEOUT_MS = 135 * 60_000;
|
|
307
|
+
export const TRANSIENT_RETRY_DELAYS_MS = Object.freeze([15_000, 30_000]);
|
|
308
|
+
const sleepSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
309
|
+
const exitOf = result => typeof result === 'string' ? 0 : (result?.exitCode ?? result?.status ?? result?.code ?? null);
|
|
310
|
+
export const TRANSIENT_NETWORK = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|network|timed out|\bE5\d\d\b|\b50[234]\b/i;
|
|
311
|
+
/** Three tries of `attempt` (15 s, then 30 s apart) while `transient(error)` says the failure was the network's. */
|
|
312
|
+
export function withTransientRetry(attempt, { transient = () => true, sleep = sleepSync, delays = TRANSIENT_RETRY_DELAYS_MS } = {}) {
|
|
313
|
+
for (let i = 0; ; i++) {
|
|
314
|
+
try { return attempt(i); } catch (error) { if (i >= delays.length || !transient(error)) throw error; sleep(delays[i]); }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync }) {
|
|
290
318
|
const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
|
|
291
319
|
if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
|
|
292
320
|
const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
|
|
321
|
+
// Exit codes are forge-update's: 0 current, 10 newer, 2 network (retried), 5 incompatible with this runtime.
|
|
322
|
+
const exec = (args, options) => {
|
|
323
|
+
if (!run.receipt) { run(node, args, options); return 0; }
|
|
324
|
+
const result = run.receipt(node, args, options);
|
|
325
|
+
if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}`);
|
|
326
|
+
return exitOf(result);
|
|
327
|
+
};
|
|
293
328
|
function check() {
|
|
294
329
|
const started = Date.now();
|
|
295
|
-
|
|
296
|
-
|
|
330
|
+
const code = withTransientRetry(() => {
|
|
331
|
+
const status = exec([updater, '--check', '--result-file', file], { timeout: 180_000, allowed: [0, 10, 2, 5] });
|
|
332
|
+
if (status === 2) throw Object.assign(Error('corpus check could not reach the release (exit 2)'), { transient: true });
|
|
333
|
+
return status;
|
|
334
|
+
}, { transient: error => error.transient === true, sleep });
|
|
335
|
+
if (code === 5) return { currencyVerdict: 'INCOMPATIBLE', exitCode: 5 };
|
|
336
|
+
if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}`);
|
|
337
|
+
let receipt;
|
|
338
|
+
try { receipt = read(file); } finally { fs.rmSync(file, { force: true }); }
|
|
297
339
|
if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
|
|
298
340
|
return receipt;
|
|
299
341
|
}
|
|
342
|
+
const installer = path.join(root, 'ruvnet-brain/bin/install.mjs');
|
|
343
|
+
const apply = () => {
|
|
344
|
+
if (!exists(installer)) throw Error('existing Brain installer absent');
|
|
345
|
+
// 12 is cleanup-pending: the knowledge was applied and the next run finishes the cleanup (shared exit contract).
|
|
346
|
+
const code = exec([installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: INSTALL_UPDATE_TIMEOUT_MS, allowed: [0, 12] });
|
|
347
|
+
if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}`);
|
|
348
|
+
return code;
|
|
349
|
+
};
|
|
300
350
|
const before = check();
|
|
351
|
+
if (before.currencyVerdict === 'INCOMPATIBLE') {
|
|
352
|
+
// A remembered rejection keyed to the KB's runtime stamp blocks every --check; only the installer re-stamps it. When the
|
|
353
|
+
// installed package is a different version than the stamp, the installer is what clears it (AuditC #5).
|
|
354
|
+
let installed = null, stamped = null;
|
|
355
|
+
try { installed = read(path.join(root, 'ruvnet-brain/package.json')).version || null; } catch { /* no installed package */ }
|
|
356
|
+
try { stamped = read(path.join(kb, 'RUNTIME-IDENTITY.json')).brainVersion || null; } catch { /* no stamp */ }
|
|
357
|
+
if (!installed || installed === stamped) return { before, state: 'incompatible', installed, stamped };
|
|
358
|
+
if (dryRun) return { before, state: 'update-available', reason: 'runtime stamp differs from the installed package', installed, stamped };
|
|
359
|
+
const installerExit = apply(), after = check();
|
|
360
|
+
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error(`corpus update failed to converge after re-stamping ${stamped} -> ${installed}`);
|
|
361
|
+
return { before, after, state: 'updated', installerExit, restamped: { from: stamped, to: installed } };
|
|
362
|
+
}
|
|
301
363
|
if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') return { before, after: before, state: before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved' };
|
|
302
364
|
// UNKNOWN is what a tree installed from a code release reports (no corpus generation stamp): forge-update
|
|
303
365
|
// documents it as apply-allowed, exactly like UPDATE_AVAILABLE, and the check after the apply must reach
|
|
304
366
|
// CURRENT or REFUSED. Throwing here failed every scheduled night until someone ran --update by hand.
|
|
305
367
|
if (before.currencyVerdict !== 'UPDATE_AVAILABLE' && before.currencyVerdict !== 'UNKNOWN') throw Error(`corpus currency unverified: ${before.currencyVerdict}`);
|
|
306
368
|
if (dryRun) return { before, state: 'update-available' };
|
|
307
|
-
const
|
|
308
|
-
if (!exists(installer)) throw Error('existing Brain installer absent');
|
|
309
|
-
run(node, [installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: 1_800_000 });
|
|
369
|
+
const installerExit = apply();
|
|
310
370
|
const after = check();
|
|
311
371
|
if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
|
|
312
|
-
return { before, after, state: 'updated' };
|
|
372
|
+
return { before, after, state: 'updated', installerExit };
|
|
373
|
+
}
|
|
374
|
+
/** Corpus check results this coordinator left behind (a run killed between check and delete) older than 48 h. */
|
|
375
|
+
export function pruneCorpusResults(brainHome, now = Date.now()) {
|
|
376
|
+
let removed = 0;
|
|
377
|
+
for (const name of (() => { try { return fs.readdirSync(brainHome); } catch { return []; } })()) {
|
|
378
|
+
if (!/^nightly-suite-corpus-[\w-]+\.json$/.test(name)) continue;
|
|
379
|
+
const file = path.join(brainHome, name);
|
|
380
|
+
try { if (fs.lstatSync(file).isFile() && now - fs.lstatSync(file).mtimeMs > 48 * 3_600_000) { fs.rmSync(file); removed++; } } catch { /* raced */ }
|
|
381
|
+
}
|
|
382
|
+
return removed;
|
|
313
383
|
}
|
|
314
384
|
export function developerUpdatePaths({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
|
|
315
385
|
return { home, brainHome, config: path.join(brainHome, 'developer-update-config.json'),
|
|
@@ -349,7 +419,7 @@ export function locateExecutable(name, { env = process.env, platform = process.p
|
|
|
349
419
|
}
|
|
350
420
|
return null;
|
|
351
421
|
}
|
|
352
|
-
export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env } = {}) {
|
|
422
|
+
export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync } = {}) {
|
|
353
423
|
if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
|
|
354
424
|
const paths = developerUpdatePaths({ home, brainHome });
|
|
355
425
|
const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
|
|
@@ -374,7 +444,7 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
374
444
|
run.receipt = (command, args, options = {}) => dispatch(command, args, { ...options, capture: true });
|
|
375
445
|
const locate = name => locateExecutable(name, { env: childEnv });
|
|
376
446
|
const receipt = { schemaVersion: 1, kind: 'nightly-suite-update', runId: crypto.randomUUID(), pid: process.pid,
|
|
377
|
-
startedAt: new Date().toISOString(), mode, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
|
|
447
|
+
startedAt: new Date().toISOString(), mode, knowledgeOnly, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
|
|
378
448
|
sourceSha256: null, ownerToken: lock.token, ownerPid: lock.ownerPid, schedulerIdentity: env.RUVNET_NIGHTLY_IDENTITY || null };
|
|
379
449
|
const scheduledAttempt = value => value?.mode === 'apply' && value.scheduled !== false && !!value.schedulerIdentity;
|
|
380
450
|
const readAttempt = file => {
|
|
@@ -418,42 +488,71 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
418
488
|
// Bind every subsequent install to this exact prefix instead of inherited npm config.
|
|
419
489
|
childEnv.npm_config_prefix = prefix;
|
|
420
490
|
receipt.npmIdentity = { npm, prefix, root, node: process.execPath };
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (needed) {
|
|
433
|
-
try { published = JSON.parse(run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 })); }
|
|
434
|
-
catch { published = {}; }
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
|
|
491
|
+
// Each stage is isolated: a failure is recorded on its stage and the run goes on, so one npm view, one plugin or a missing
|
|
492
|
+
// CLI can no longer cost the night its knowledge update (AuditC #1). Packages run before knowledge so a new code package's
|
|
493
|
+
// installer applies its matching corpus; knowledge is always attempted when the KB exists.
|
|
494
|
+
const failures = [];
|
|
495
|
+
const stage = async (name, body, onError = () => {}) => {
|
|
496
|
+
try { return await body(); } catch (error) { failures.push({ stage: name, error: error.message }); onError(error); persistReceipt(); return undefined; }
|
|
497
|
+
};
|
|
498
|
+
const viewRetry = attempt => withTransientRetry(attempt, { transient: error => TRANSIENT_NETWORK.test(error.message), sleep });
|
|
499
|
+
if (!knowledgeOnly) {
|
|
500
|
+
// An inventory failure is recorded in failures; no package is then touched, and knowledge still runs.
|
|
501
|
+
receipt.before = (await stage('inventory', () => discover(root, prefix, config.scope))) ?? [];
|
|
438
502
|
persistReceipt();
|
|
503
|
+
for (const before of receipt.before) {
|
|
504
|
+
await stage(`package ${before.name}`, () => {
|
|
505
|
+
const tags = before.localSource ? {} : JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 })));
|
|
506
|
+
// A tracked non-latest tag (alpha, Kit's next) needs publish times to tell a newer pre-release from an abandoned
|
|
507
|
+
// one (developer-update-policy pickTargetTag); fetched only when that tag and latest name different versions. A
|
|
508
|
+
// failed lookup is {} — required but unknown — so the pre-release is skipped, never installed blind.
|
|
509
|
+
let published = null;
|
|
510
|
+
if (!before.localSource) {
|
|
511
|
+
let needed = false;
|
|
512
|
+
try { needed = needsPublishTimes(normalizeNpmDistTags(tags), wantedTag(before.name, config.channel)); } catch { needed = false; }
|
|
513
|
+
if (needed) {
|
|
514
|
+
try { published = JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 }))); }
|
|
515
|
+
catch { published = {}; }
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
|
|
519
|
+
}, error => receipt.steps.push({ name: before.name, state: 'failed', error: error.message, before }));
|
|
520
|
+
persistReceipt();
|
|
521
|
+
}
|
|
439
522
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
receipt.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
523
|
+
if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) {
|
|
524
|
+
receipt.knowledge = await stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep }),
|
|
525
|
+
error => { receipt.knowledge = { state: 'failed', error: error.message }; }) ?? receipt.knowledge;
|
|
526
|
+
pruneCorpusResults(paths.brainHome);
|
|
527
|
+
} else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
|
|
528
|
+
persistReceipt();
|
|
529
|
+
if (!knowledgeOnly) {
|
|
530
|
+
receipt.plugins = await stage('plugins', () => synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY }));
|
|
531
|
+
receipt.maintenance = await stage('maintenance', () => maintenance(config, run, mode === 'check', { home, brainHome: paths.brainHome, locate, node: process.execPath, progress: result => { receipt.maintenance = result; persistReceipt(); } }),
|
|
532
|
+
error => { for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; } }) ?? receipt.maintenance;
|
|
533
|
+
receipt.cleanup = await stage('cleanup', () => cleanupNpxDuplicates({ home, globalRoot: root, enabled: config.cleanup && mode === 'apply', run }));
|
|
534
|
+
receipt.after = await stage('inventory-after', () => discover(root, prefix, config.scope));
|
|
535
|
+
} else receipt.notes.push('knowledge-only run: packages, plugins and maintenance are left to the scheduled nightly');
|
|
536
|
+
receipt.failures = failures; receipt.ok = failures.length === 0;
|
|
537
|
+
receipt.state = failures.length ? 'completed-with-failures' : mode === 'check' ? 'checked' : 'completed';
|
|
538
|
+
receipt.coverage = { scope: knowledgeOnly ? 'knowledge-only' : config.scope, excluded: receipt.steps.filter(s => /preserved$/.test(s.state)).map(s => ({ name: s.name, reason: s.state })),
|
|
448
539
|
ahead: receipt.steps.filter(s => s.state === 'ahead-preserved').map(s => s.name),
|
|
449
540
|
preservedLocalModification: receipt.steps.filter(s => s.reason === 'preservedLocalModification').map(s => s.name),
|
|
450
|
-
unverified: [...new Set([...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
|
|
541
|
+
unverified: [...new Set([...failures.map(f => `${f.stage}: ${f.error}`), ...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
|
|
542
|
+
if (failures.length) {
|
|
543
|
+
// Throw only after the receipt says which stages failed: the runner still exits 1 exactly as before.
|
|
544
|
+
receipt.error = failures.map(f => `${f.stage}: ${f.error}`).join('; ');
|
|
545
|
+
persistReceipt();
|
|
546
|
+
throw Object.assign(Error(`developer update completed with failures: ${receipt.error}`), { completedWithFailures: true });
|
|
547
|
+
}
|
|
451
548
|
return receipt;
|
|
452
549
|
} catch (error) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
550
|
+
if (!error.completedWithFailures) {
|
|
551
|
+
receipt.state = 'failed'; receipt.error = error.message;
|
|
552
|
+
for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; }
|
|
553
|
+
receipt.coverage = { scope: config.scope, unverified: [error.message], notes: receipt.notes };
|
|
554
|
+
try { if (receipt.npmIdentity) receipt.after = discover(receipt.npmIdentity.root, receipt.npmIdentity.prefix, config.scope); } catch (e) { receipt.snapshotError = e.message; }
|
|
555
|
+
}
|
|
457
556
|
throw error;
|
|
458
557
|
} finally {
|
|
459
558
|
receipt.finishedAt = new Date().toISOString();
|
|
@@ -462,19 +561,22 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
|
|
|
462
561
|
}
|
|
463
562
|
export async function developerUpdateCli(args = process.argv.slice(2)) {
|
|
464
563
|
if (args.includes('--help') || args.includes('-h')) {
|
|
465
|
-
console.log('Usage: ruvnet-brain-update [--check|--apply] [--channel latest|alpha] [--scope ruvnet|all]');
|
|
564
|
+
console.log('Usage: ruvnet-brain-update [--check|--apply] [--knowledge-only] [--channel latest|alpha] [--scope ruvnet|all]');
|
|
466
565
|
return;
|
|
467
566
|
}
|
|
468
|
-
|
|
567
|
+
// --knowledge-only: npm identity plus the knowledge step under the same lock (the SessionStart self-heal); the full suite
|
|
568
|
+
// runs only from the scheduled runner.
|
|
569
|
+
let mode = 'apply', knowledgeOnly = false; const override = readDeveloperUpdateConfig();
|
|
469
570
|
for (let i = 0; i < args.length; i++) {
|
|
470
|
-
if (args[i] === '--
|
|
571
|
+
if (args[i] === '--knowledge-only') knowledgeOnly = true;
|
|
572
|
+
else if (args[i] === '--check' || args[i] === '--dry-run') mode = 'check';
|
|
471
573
|
else if (args[i] === '--apply') mode = 'apply';
|
|
472
574
|
else if (args[i] === '--channel') override.channel = args[++i];
|
|
473
575
|
else if (args[i] === '--scope') override.scope = args[++i];
|
|
474
576
|
else throw Error(`unsupported developer update argument: ${args[i]}`);
|
|
475
577
|
}
|
|
476
|
-
const result = await runDeveloperUpdate({ mode, config: override });
|
|
578
|
+
const result = await runDeveloperUpdate({ mode, config: override, knowledgeOnly });
|
|
477
579
|
console.log(JSON.stringify(result));
|
|
478
580
|
return result;
|
|
479
581
|
}
|
|
480
|
-
if (
|
|
582
|
+
if (isDirectInvocation(import.meta.url)) developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
@@ -58,6 +58,7 @@ import path from 'node:path';
|
|
|
58
58
|
import crypto from 'node:crypto';
|
|
59
59
|
import { spawnSync } from 'node:child_process';
|
|
60
60
|
import { fileURLToPath } from 'node:url';
|
|
61
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
61
62
|
|
|
62
63
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
63
64
|
export const REPO_ROOT = path.resolve(HERE, '..', '..');
|
|
@@ -974,20 +975,5 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
974
975
|
return 1;
|
|
975
976
|
}
|
|
976
977
|
|
|
977
|
-
|
|
978
|
-
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
979
|
-
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
980
|
-
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
981
|
-
// from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
|
|
982
|
-
// archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
983
|
-
function isDirectInvocation() {
|
|
984
|
-
try {
|
|
985
|
-
if (!process.argv[1]) return false;
|
|
986
|
-
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
987
|
-
} catch {
|
|
988
|
-
return false;
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
const invokedDirectly = isDirectInvocation();
|
|
978
|
+
const invokedDirectly = isDirectInvocation(import.meta.url);
|
|
993
979
|
if (invokedDirectly) process.exit(main());
|
|
@@ -45,6 +45,7 @@ import os from 'node:os';
|
|
|
45
45
|
import path from 'node:path';
|
|
46
46
|
import { spawnSync } from 'node:child_process';
|
|
47
47
|
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
48
49
|
|
|
49
50
|
export const SCOPE = ['scripts/', 'kb/', 'plugin/scripts/', 'tests/', 'bin/'];
|
|
50
51
|
export const THRESHOLD = 0.5;
|
|
@@ -485,7 +486,7 @@ export function evaluate(payload, { env = process.env, now = Date.now(), deadlin
|
|
|
485
486
|
}
|
|
486
487
|
|
|
487
488
|
const isMain = (() => {
|
|
488
|
-
try { return
|
|
489
|
+
try { return isDirectInvocation(import.meta.url); }
|
|
489
490
|
catch { return false; }
|
|
490
491
|
})();
|
|
491
492
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// plugin/scripts/entrypoint.mjs — the ONE answer to "was this module run as the program, or imported?"
|
|
2
|
+
//
|
|
3
|
+
// Every dual-purpose file in this repo (a library when imported, a CLI when run) asks that question.
|
|
4
|
+
// The copy-pasted answer compared `process.argv[1]` with `import.meta.url` directly, e.g.
|
|
5
|
+
// path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
6
|
+
// import.meta.url === pathToFileURL(process.argv[1]).href
|
|
7
|
+
// argv[1] is the path as the caller typed it, symlinks included; Node resolves symlinks before it sets
|
|
8
|
+
// import.meta.url. Run through a symlink (npm bin shims, wrapper scripts, a symlinked checkout, every
|
|
9
|
+
// os.tmpdir() path on macOS) the two sides differ, main() never runs, and the process exits 0 having
|
|
10
|
+
// done nothing. A release gate that exits 0 without checking anything passes. Dream Cycle PRs #295,
|
|
11
|
+
// #317, #333, #367, #412 and #484 each found one more copy by hand.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// import { isDirectInvocation } from './entrypoint.mjs'; // path relative to this file
|
|
15
|
+
// if (isDirectInvocation(import.meta.url)) main();
|
|
16
|
+
//
|
|
17
|
+
// tests/unit/entrypoint-guard-sweep.test.mjs fails when a raw comparison reappears outside this file,
|
|
18
|
+
// and runs a script through a symlink to prove this helper makes it execute.
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when `moduleUrl` (pass `import.meta.url`) is the file Node was asked to run. Both sides are
|
|
24
|
+
* compared as real paths. Never throws: `realpathSync` throws on a path that does not resolve (under
|
|
25
|
+
* a test runner argv[1] need not be a real file), and an imported module must not crash its importer.
|
|
26
|
+
*/
|
|
27
|
+
export function isDirectInvocation(moduleUrl, argv1 = process.argv[1]) {
|
|
28
|
+
if (!argv1 || !moduleUrl) return false;
|
|
29
|
+
try {
|
|
30
|
+
return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// footprint-io.mjs — the footprint sweep's disk operations (ADR-0098), kept apart from the classifier in
|
|
2
|
-
// brain-footprint.mjs: byte counts, log rotation, the guarded removal,
|
|
2
|
+
// brain-footprint.mjs: byte counts, log rotation, the guarded removal, the rescue of a released copy's
|
|
3
|
+
// operational files, and the KEPT-proof cache.
|
|
3
4
|
//
|
|
4
5
|
// THE KEPT-PROOF CACHE (independent review S7).
|
|
5
6
|
// kbCopyProof hashes every file of a GB-sized copy. A copy kept because it holds data the live brain lacks
|
|
@@ -7,6 +8,7 @@
|
|
|
7
8
|
// which can only keep it again. Only KEPT results are cached, keyed by a cheap stat fingerprint of the copy
|
|
8
9
|
// and of the live brain (no hashing, no tree walk); a stale entry can only KEEP a copy, never remove one, and
|
|
9
10
|
// any change to the live brain's identity files (a restore, an update) invalidates it.
|
|
11
|
+
import crypto from 'node:crypto';
|
|
10
12
|
import fs from 'node:fs';
|
|
11
13
|
import path from 'node:path';
|
|
12
14
|
|
|
@@ -157,7 +159,43 @@ export function removeWithin(target, expectedParent, owned) {
|
|
|
157
159
|
return size;
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
/**
|
|
163
|
+
* RESCUE BEFORE RELEASE. kbCopyProof lists a disposable copy's operational files (logs, ruflo scratch) as
|
|
164
|
+
* `rescue`, each with the bytes it was proven with. They are copied to <brainHome>/kb-copy-rescued/<copy
|
|
165
|
+
* name>/<path> and verified there BEFORE the copy is removed. Any mismatch (the source changed since the
|
|
166
|
+
* proof, a different file already at the destination, a link in the way) throws, and the caller keeps the
|
|
167
|
+
* copy. Retention of the rescued directories is brain-footprint.mjs's (FOOTPRINT_POLICY.rescuedCopiesKept).
|
|
168
|
+
*/
|
|
169
|
+
export const RESCUE_DIR = 'kb-copy-rescued';
|
|
170
|
+
const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
171
|
+
const regular = (file) => { const st = lstat(file); return Boolean(st && st.isFile() && !st.isSymbolicLink()); };
|
|
172
|
+
export function rescueOperationalFiles(copyDir, rescue, brainHome, name = path.basename(copyDir)) {
|
|
173
|
+
if (!rescue?.length) return null;
|
|
174
|
+
const root = path.join(brainHome, RESCUE_DIR); const dest = path.join(root, name);
|
|
175
|
+
for (const dir of [brainHome, root, dest]) {
|
|
176
|
+
if (lstat(dir)?.isSymbolicLink()) throw new Error(`refusing to rescue into ${dir}: it is a link`);
|
|
177
|
+
}
|
|
178
|
+
for (const { file, sha256 } of rescue) {
|
|
179
|
+
const from = path.join(copyDir, file); const to = path.join(dest, file);
|
|
180
|
+
if (!regular(from) || sha256Of(from) !== sha256) throw new Error(`${file} changed after the copy was proven; the copy is kept`);
|
|
181
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
182
|
+
if (!lstat(to)) fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
183
|
+
if (!regular(to) || sha256Of(to) !== sha256) throw new Error(`could not rescue ${file} to ${to} (a different file is there); the copy is kept`);
|
|
184
|
+
}
|
|
185
|
+
// A reused rescue folder (an earlier sweep rescued, then could not release, this copy) holds the only copy of these
|
|
186
|
+
// logs once the copy goes: make it the newest, so retention keeps it among the newest five (review r2). A failure
|
|
187
|
+
// throws, and the caller keeps the copy.
|
|
188
|
+
const touched = new Date(); fs.utimesSync(dest, touched, touched);
|
|
189
|
+
return dest;
|
|
190
|
+
}
|
|
191
|
+
|
|
160
192
|
const PROOF_CACHE = '.footprint-proof-cache.json';
|
|
193
|
+
// #335: schema 1 KEPT proofs predate kept-sibling accounting (two identical preserved copies each kept the
|
|
194
|
+
// other forever); they are proven once more. A dropped cache entry can only cost a re-hash, never a byte.
|
|
195
|
+
const PROOF_CACHE_SCHEMA = 2;
|
|
196
|
+
// The proof rule a cached KEPT verdict was reached under. A verdict from an older rule (one that kept copies
|
|
197
|
+
// the current rule releases, e.g. for a log file before rescue existed) is not reused; that copy is proven again.
|
|
198
|
+
const PROOF_RULE = 2;
|
|
161
199
|
const IDENTITY_FILES = ['SOURCE.json', 'PRIVATE-STORES.json', 'COVERAGE.json', 'RVF-GENERATIONS.json'];
|
|
162
200
|
const statKey = (file) => { try { const st = fs.lstatSync(file); return `${st.size}:${Math.floor(st.mtimeMs)}`; } catch { return '-'; } };
|
|
163
201
|
const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey(path.join(dir, f)))].join('|');
|
|
@@ -165,21 +203,21 @@ const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey
|
|
|
165
203
|
export function readProofCache(brainHome) {
|
|
166
204
|
try {
|
|
167
205
|
const doc = JSON.parse(fs.readFileSync(path.join(brainHome, PROOF_CACHE), 'utf8'));
|
|
168
|
-
if (doc && typeof doc.entries === 'object' && doc.entries) return doc;
|
|
206
|
+
if (doc && doc.schemaVersion === PROOF_CACHE_SCHEMA && typeof doc.entries === 'object' && doc.entries) return doc;
|
|
169
207
|
} catch { /* none yet */ }
|
|
170
|
-
return { schemaVersion:
|
|
208
|
+
return { schemaVersion: PROOF_CACHE_SCHEMA, entries: {} };
|
|
171
209
|
}
|
|
172
210
|
|
|
173
211
|
/** The cached KEPT proof for this copy, if neither the copy nor the live brain changed since. */
|
|
174
212
|
export function cachedKept(cache, copyDir, liveDir) {
|
|
175
213
|
const hit = cache.entries[copyDir];
|
|
176
|
-
return hit && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
|
|
214
|
+
return hit && hit.rule === PROOF_RULE && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
|
|
177
215
|
}
|
|
178
216
|
|
|
179
217
|
export function rememberKept(brainHome, copyDir, liveDir, proof) {
|
|
180
218
|
try {
|
|
181
219
|
const cache = readProofCache(brainHome);
|
|
182
|
-
cache.entries[copyDir] = { copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
|
|
220
|
+
cache.entries[copyDir] = { rule: PROOF_RULE, copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
|
|
183
221
|
unique: (proof.unique || []).slice(0, 10), at: new Date().toISOString() };
|
|
184
222
|
const file = path.join(brainHome, PROOF_CACHE);
|
|
185
223
|
fs.writeFileSync(`${file}.tmp-${process.pid}`, JSON.stringify(cache));
|
|
@@ -187,7 +225,96 @@ export function rememberKept(brainHome, copyDir, liveDir, proof) {
|
|
|
187
225
|
} catch { /* uncached: the next sweep proves it again, which is only slower */ }
|
|
188
226
|
}
|
|
189
227
|
|
|
190
|
-
/** The honest remedy for a copy
|
|
191
|
-
export const keptCopyFix = (copyDir, hit) => `
|
|
228
|
+
/** The honest remedy for a kept copy: what it holds, and the explicit archive command that releases it (#335). */
|
|
229
|
+
export const keptCopyFix = (copyDir, hit) => `npx ruvnet-brain --clean --archive — ${copyDir} is kept because it holds ${
|
|
192
230
|
(hit.unique || []).some((u) => /private/i.test(u.why || '')) ? 'private ' : ''}data the live brain lacks (${
|
|
193
|
-
(hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason});
|
|
231
|
+
(hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason}); that copies only those files into kb.archive/ and releases the copy (or restore them into the live brain yourself)`;
|
|
232
|
+
|
|
233
|
+
const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
234
|
+
/**
|
|
235
|
+
* Every `<archiveDir>/<stamp>/<label>` tree `--clean --archive` wrote: real directories only, links never entered.
|
|
236
|
+
* A dot-named entry is an archive still being written (or one a killed sweep left half-written); it is never
|
|
237
|
+
* a reference, so a partial archive can never account for a copy's bytes.
|
|
238
|
+
*/
|
|
239
|
+
export function archiveRoots(archiveDir) {
|
|
240
|
+
const real = (p) => { const st = lstat(p); return Boolean(st && st.isDirectory() && !st.isSymbolicLink()); };
|
|
241
|
+
if (!real(archiveDir)) return [];
|
|
242
|
+
return names(archiveDir).map((s) => path.join(archiveDir, s)).filter(real)
|
|
243
|
+
.flatMap((stampDir) => names(stampDir).filter((n) => !n.startsWith('.')).map((n) => path.join(stampDir, n)).filter(real));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Undo an archive this sweep wrote (the copy it came from is being kept, so the archive holds nothing unique). */
|
|
247
|
+
export function discardArchive(root) {
|
|
248
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
249
|
+
fs.rmSync(`${root}.json`, { force: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Discard an archive ONLY while the copy it came from still holds every archived byte (review 4.7.21 r2). Between
|
|
254
|
+
* the archive and the re-proof another process (an updater's reclaimBackups, which counts this archive as
|
|
255
|
+
* evidence) may have removed the copy; a re-proof of a missing directory still says "keep", so an unconditional
|
|
256
|
+
* discard would lose the bytes in both places. The copy must still be a real directory and each archived file a
|
|
257
|
+
* regular file there with the same SHA-256; otherwise the archive is kept and the reason is returned.
|
|
258
|
+
*/
|
|
259
|
+
export function discardArchiveIfMirrored({ root, copyDir, files }) {
|
|
260
|
+
const dir = lstat(copyDir);
|
|
261
|
+
if (!dir || dir.isSymbolicLink() || !dir.isDirectory()) return { discarded: false, reason: `${copyDir} is no longer a real directory` };
|
|
262
|
+
for (const { file, sha256 } of files || []) {
|
|
263
|
+
const held = path.join(copyDir, file);
|
|
264
|
+
let same = false;
|
|
265
|
+
try { same = regular(held) && sha256File(held) === sha256; } catch { same = false; }
|
|
266
|
+
if (!same) return { discarded: false, reason: `${file} is no longer identical in ${copyDir}` };
|
|
267
|
+
}
|
|
268
|
+
discardArchive(root);
|
|
269
|
+
return { discarded: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* #335 `--clean --archive` (explicit, never automatic): COPY exactly the files a KEPT proof named unique into
|
|
274
|
+
* `<stampDir>/<label>/<same relative path>`, verify each byte-for-byte, and write `<stampDir>/<label>.json`.
|
|
275
|
+
* Moves and deletes nothing: the caller re-proves the copy against this archive before releasing it. A link,
|
|
276
|
+
* an unsafe ancestor, a non-regular file, too little free space or any copy error archives nothing usable.
|
|
277
|
+
* ALL OR NOTHING: the files go to a dot-named temp sibling and are renamed to `<label>` only after every one
|
|
278
|
+
* verifies (the receipt likewise); a failure removes the temp, so no half-written `<label>` is ever left for a
|
|
279
|
+
* later sweep to compare against.
|
|
280
|
+
*/
|
|
281
|
+
export function archiveUniqueFiles({ copyDir, unique, stampDir, label, now = Date.now() }) {
|
|
282
|
+
if (!Array.isArray(unique) || !unique.length) return { ok: false, reason: 'no file is named unique; the copy is kept for another reason' };
|
|
283
|
+
const root = path.join(stampDir, label);
|
|
284
|
+
if (lstat(root)) return { ok: false, reason: `${root} already exists` };
|
|
285
|
+
let need = 0;
|
|
286
|
+
for (const { file, why } of unique) {
|
|
287
|
+
const st = lstat(path.join(copyDir, file));
|
|
288
|
+
if (/link|ancestor/i.test(String(why)) || !st || !st.isFile() || st.isSymbolicLink()) return { ok: false, reason: `${file} is not a regular file that can be archived (${why})` };
|
|
289
|
+
need += st.size;
|
|
290
|
+
}
|
|
291
|
+
const temp = path.join(stampDir, `.${label}.partial-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
292
|
+
const receipt = path.join(stampDir, `${label}.json`); const receiptTemp = `${temp}.json`;
|
|
293
|
+
let renamed = false;
|
|
294
|
+
try {
|
|
295
|
+
fs.mkdirSync(stampDir, { recursive: true });
|
|
296
|
+
const free = typeof fs.statfsSync === 'function' ? fs.statfsSync(stampDir) : null;
|
|
297
|
+
if (free && Number(free.bavail) * Number(free.bsize) < need + 64 * 1024 * 1024) {
|
|
298
|
+
return { ok: false, reason: `not enough free space to archive ${need} bytes` };
|
|
299
|
+
}
|
|
300
|
+
const files = [];
|
|
301
|
+
for (const { file } of unique) {
|
|
302
|
+
const src = path.join(copyDir, file); const dst = path.join(temp, file);
|
|
303
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
304
|
+
fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);
|
|
305
|
+
const sha256 = sha256File(src);
|
|
306
|
+
if (sha256File(dst) !== sha256) throw new Error(`archived bytes of ${file} differ from the source`);
|
|
307
|
+
files.push({ file, bytes: fs.lstatSync(dst).size, sha256 });
|
|
308
|
+
}
|
|
309
|
+
fs.writeFileSync(receiptTemp, `${JSON.stringify({ schemaVersion: 1, kind: 'ruvnet-brain-kb-archive',
|
|
310
|
+
source: copyDir, archivedAt: new Date(now).toISOString(), files }, null, 2)}\n`, { flag: 'wx' });
|
|
311
|
+
if (lstat(root) || lstat(receipt)) throw new Error(`${root} appeared while archiving`);
|
|
312
|
+
fs.renameSync(temp, root); renamed = true;
|
|
313
|
+
fs.renameSync(receiptTemp, receipt);
|
|
314
|
+
return { ok: true, root, files, bytes: need };
|
|
315
|
+
} catch (error) {
|
|
316
|
+
fs.rmSync(temp, { recursive: true, force: true }); fs.rmSync(receiptTemp, { force: true });
|
|
317
|
+
if (renamed) fs.rmSync(root, { recursive: true, force: true });
|
|
318
|
+
return { ok: false, reason: `archive failed: ${error.message}` };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -36,6 +36,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
36
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
37
|
import { normalizeHostEvent } from './hook-input.mjs';
|
|
38
38
|
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
39
|
+
import { isDirectInvocation } from './entrypoint.mjs';
|
|
39
40
|
|
|
40
41
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
41
42
|
const PLUGIN_ROOT = path.resolve(HERE, '..');
|
|
@@ -155,5 +156,5 @@ async function main() {
|
|
|
155
156
|
return out.code;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
const isMain = (
|
|
159
|
+
const isMain = isDirectInvocation(import.meta.url);
|
|
159
160
|
if (isMain) process.exit(await main().catch(() => 0));
|