ruvnet-brain 4.3.21 → 4.3.26
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 +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +189 -9
- package/console/index.html +70 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/style.css +26 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +364 -28
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +47 -10
- package/scripts/release-transaction.mjs +40 -11
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<title>RuvNet Brain — what's in the brain</title>
|
|
8
|
+
<!-- Same version-free brain mark as the console. -->
|
|
9
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect x=%222%22 y=%222%22 width=%2260%22 height=%2260%22 rx=%2216%22 fill=%22%230b0d12%22 stroke=%22%232c3850%22 stroke-width=%221.5%22/><path d=%22M25 48 C17 48 11.5 41.5 13.5 34.5 C10 29 13 21 19 19 C21 12.5 29 9.5 35 12 C43 9 50.5 14.5 50.5 22 C55 26 55 34.5 50 38 C49 43.5 43 47.5 37 46.5 L31 47.5 Z%22 fill=%22none%22 stroke=%22%23f0a830%22 stroke-width=%223.4%22 stroke-linejoin=%22round%22/><path d=%22M31 47 L31 33 M31 33 L24 27 M31 33 L40 24.5 M31 33 L41 36.5%22 fill=%22none%22 stroke=%22%23f0a830%22 stroke-width=%222.4%22 stroke-linecap=%22round%22 opacity=%22.9%22/><circle cx=%2224%22 cy=%2227%22 r=%223.4%22 fill=%22%235ad6ff%22/><circle cx=%2240%22 cy=%2224.5%22 r=%223.4%22 fill=%22%23f4b13a%22/><circle cx=%2241%22 cy=%2236.5%22 r=%223.4%22 fill=%22%235fd38a%22/></svg>" />
|
|
10
|
+
|
|
11
|
+
<script>
|
|
12
|
+
/* Resolve theme BEFORE first paint — same mechanism as the console (index.html). */
|
|
13
|
+
(function () {
|
|
14
|
+
var t = null;
|
|
15
|
+
try { t = localStorage.getItem('rbc-theme'); } catch (e) {}
|
|
16
|
+
if (t !== 'light' && t !== 'dark') {
|
|
17
|
+
try { t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; }
|
|
18
|
+
catch (e) { t = 'dark'; }
|
|
19
|
+
}
|
|
20
|
+
document.documentElement.dataset.theme = t;
|
|
21
|
+
})();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<link rel="stylesheet" href="style.css" />
|
|
25
|
+
<link rel="stylesheet" href="scope.css" />
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
|
|
29
|
+
<!-- ============================================================================
|
|
30
|
+
WHAT'S IN THE BRAIN — the coverage page the owner asked for on 2026-09-11.
|
|
31
|
+
|
|
32
|
+
One question, answered per row from the installed brain's OWN receipts: for every repo and
|
|
33
|
+
gist rUv publishes, when did rUv last change it, and when did the brain last read it. The
|
|
34
|
+
verdict is never the shipped `status` word (release-projection stamps every seeded row
|
|
35
|
+
CURRENT); it is `artifact.sourceCommit === upstream.sha` for repos and the gist receipt's
|
|
36
|
+
`versionSha === upstream.sha` for gists — see scripts/onboarding-console.mjs gatherScope().
|
|
37
|
+
|
|
38
|
+
HONESTY CONSTRAINT: the record this page reads shipped WITH the installed bytes and carries
|
|
39
|
+
its own `observedAt`. That date is printed first, before any count, and the page never says
|
|
40
|
+
"live". Every number here is computed from the payload; nothing is typed into this file.
|
|
41
|
+
============================================================================ -->
|
|
42
|
+
|
|
43
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
44
|
+
|
|
45
|
+
<header class="head">
|
|
46
|
+
<div class="wrap head-inner">
|
|
47
|
+
<div class="brand">
|
|
48
|
+
<svg class="mark" viewBox="0 0 64 64" aria-hidden="true">
|
|
49
|
+
<rect class="mk-plate" x="2" y="2" width="60" height="60" rx="16" />
|
|
50
|
+
<path class="mk-brain" d="M25 48 C17 48 11.5 41.5 13.5 34.5 C10 29 13 21 19 19 C21 12.5 29 9.5 35 12 C43 9 50.5 14.5 50.5 22 C55 26 55 34.5 50 38 C49 43.5 43 47.5 37 46.5 L31 47.5 Z" />
|
|
51
|
+
<path class="mk-branch" d="M31 47 L31 33 M31 33 L24 27 M31 33 L40 24.5 M31 33 L41 36.5" />
|
|
52
|
+
<circle class="n2" cx="24" cy="27" r="4" /><circle class="n1" cx="40" cy="24.5" r="4" /><circle class="n3" cx="41" cy="36.5" r="4" />
|
|
53
|
+
</svg>
|
|
54
|
+
<span class="brand-text">RuvNet Brain <span class="brand-sub">what's in the brain</span></span>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="head-right">
|
|
57
|
+
<a class="back-chip mono" href="tips.html">how to use it</a>
|
|
58
|
+
<a class="back-chip mono" href="./">‹ console</a>
|
|
59
|
+
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Switch color theme">
|
|
60
|
+
<svg class="ic-sun" viewBox="0 0 24 24" aria-hidden="true">
|
|
61
|
+
<circle cx="12" cy="12" r="4.5" />
|
|
62
|
+
<g class="rays"><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="7" y2="7"/><line x1="17" y1="17" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="7" y2="17"/><line x1="17" y1="7" x2="19.1" y2="4.9"/></g>
|
|
63
|
+
</svg>
|
|
64
|
+
<svg class="ic-moon" viewBox="0 0 24 24" aria-hidden="true">
|
|
65
|
+
<path d="M20.4 14.3A8.5 8.5 0 0 1 9.7 3.6a8.5 8.5 0 1 0 10.7 10.7Z" />
|
|
66
|
+
</svg>
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="spectrum-edge" aria-hidden="true"></div>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
<main id="main" class="wrap">
|
|
74
|
+
|
|
75
|
+
<section class="scope-lead">
|
|
76
|
+
<p class="eyebrow">coverage · every repo and gist the installed brain knows</p>
|
|
77
|
+
<h1>What's in the brain</h1>
|
|
78
|
+
<p class="scope-purpose">Is the latest thing rUv shipped in here? Search a repo or gist below; if rUv's last change is on or before the brain's read date, it's in.</p>
|
|
79
|
+
<p class="scope-rule">If rUv's last change to a repo is on or before the date the brain read it, everything in that repo is in the brain. If it's after, the brain is behind on that repo by the difference.</p>
|
|
80
|
+
<div id="asof" class="asof"><span class="dot"></span>reading the installed brain's coverage record…</div>
|
|
81
|
+
<p id="asof-note" class="muted" style="font-size:12.5px;margin:8px 0 0;"></p>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<div id="state" class="state">Reading COVERAGE.json and RVF-GENERATIONS.json from the installed store root…</div>
|
|
85
|
+
<noscript><p class="state">This page needs the console's JavaScript to render the listing — every number on it is read from the installed brain, not written here.</p></noscript>
|
|
86
|
+
|
|
87
|
+
<div id="content" hidden>
|
|
88
|
+
<div id="buckets" class="buckets"></div>
|
|
89
|
+
<p id="bucket-foot" class="bucket-foot"></p>
|
|
90
|
+
|
|
91
|
+
<!-- Three ways to look, one search — the owner's ask (2026-09-12): "alphabetically, or thematically,
|
|
92
|
+
or by date". There is no theme taxonomy in the installed data (measured), so "thematically" is
|
|
93
|
+
served honestly by searching what each repo DOES (its capability-card line), not by invented
|
|
94
|
+
categories. The buttons drive BOTH tables; a column-header click still sorts one table. -->
|
|
95
|
+
<div class="scope-views" role="group" aria-label="How to list them">
|
|
96
|
+
<span class="views-cap mono">show</span>
|
|
97
|
+
<button type="button" class="view" data-view="newest" aria-pressed="true">Newest first</button>
|
|
98
|
+
<button type="button" class="view" data-view="az" aria-pressed="false">A–Z</button>
|
|
99
|
+
<button type="button" class="view" data-view="behind" aria-pressed="false">Behind first</button>
|
|
100
|
+
<input id="search" class="search" type="search" placeholder="search repos and gists — by name or what they do…" autocomplete="off" spellcheck="false" />
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="scope-tools">
|
|
104
|
+
<h2>Repositories <span id="repos-count" class="count"></span></h2>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="tbl-wrap">
|
|
107
|
+
<table id="repos-table" class="scope">
|
|
108
|
+
<thead><tr>
|
|
109
|
+
<th data-key="name" aria-sort="none">repository</th>
|
|
110
|
+
<th data-key="ruvChangedAt" aria-sort="descending" title="date of the default-branch HEAD commit — what the brain ingests">rUv's last change</th>
|
|
111
|
+
<th data-key="brainReadAt" aria-sort="none" title="artifact.ingestedAt, or the generation ledger's builtUtc">brain read it</th>
|
|
112
|
+
<th data-key="bucket" aria-sort="none">verdict</th>
|
|
113
|
+
<th title="brain's source commit → upstream HEAD">commits</th>
|
|
114
|
+
</tr></thead>
|
|
115
|
+
<tbody id="repos-body"></tbody>
|
|
116
|
+
</table>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div class="scope-tools">
|
|
120
|
+
<h2>Gists <span id="gists-count" class="count"></span></h2>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="tbl-wrap">
|
|
123
|
+
<table id="gists-table" class="scope">
|
|
124
|
+
<thead><tr>
|
|
125
|
+
<th data-key="name" aria-sort="none">gist</th>
|
|
126
|
+
<th data-key="ruvChangedAt" aria-sort="descending" title="gist updated_at — gists have no branches, so this is the content date">rUv's last change</th>
|
|
127
|
+
<th data-key="brainReadAt" aria-sort="none">brain read it</th>
|
|
128
|
+
<th data-key="bucket" aria-sort="none">verdict</th>
|
|
129
|
+
<th title="revision the brain holds → live revision">revisions</th>
|
|
130
|
+
</tr></thead>
|
|
131
|
+
<tbody id="gists-body"></tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<p class="scope-foot">
|
|
136
|
+
<strong>How the verdict is decided.</strong> <em>Current</em> means the brain's recorded source commit (or gist revision) equals the one live upstream at the as-of date. <em>Behind</em> means they differ — rUv changed it after the brain read it. <em>Unverified</em> means the store is installed here but no source commit was recorded for it, so neither claim can be proven. <em>Not in the brain</em> means no store file exists at the installed root. The shipped status label is never used. Dates: repos use the default-branch commit date, gists their <code>updated_at</code>; the read date is the ingest receipt, falling back to the generation ledger. Store root: <code id="foot-root"></code>
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
</main>
|
|
141
|
+
|
|
142
|
+
<script src="scope.js"></script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
package/console/scope.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/* scope.js — "what's in the brain". Reads /api/scope once, renders four verdict buckets and two
|
|
2
|
+
sortable, searchable tables. Every number on this page comes from that payload; nothing is typed
|
|
3
|
+
here. With JS off the page shows the as-of rule and an honest "needs the console server" line. */
|
|
4
|
+
(function () {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
/* ── theme toggle — same key and mechanism as the console ───────────────── */
|
|
8
|
+
var KEY = 'rbc-theme';
|
|
9
|
+
var btn = document.getElementById('theme-toggle');
|
|
10
|
+
if (btn) {
|
|
11
|
+
btn.addEventListener('click', function () {
|
|
12
|
+
var t = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
|
13
|
+
document.documentElement.dataset.theme = t;
|
|
14
|
+
try { localStorage.setItem(KEY, t); } catch (e) { /* private mode is fine */ }
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
19
|
+
var esc = function (s) {
|
|
20
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
21
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
var day = function (iso) { return iso ? String(iso).slice(0, 10) : null; };
|
|
25
|
+
var BUCKET = {
|
|
26
|
+
current: { cls: 'b-current', label: 'in the brain · current' },
|
|
27
|
+
behind: { cls: 'b-behind', label: 'in the brain · behind' },
|
|
28
|
+
unverified: { cls: 'b-unverified', label: 'in the brain · currency unverified' },
|
|
29
|
+
'not-in-brain': { cls: 'b-notin', label: 'not in the brain' }
|
|
30
|
+
};
|
|
31
|
+
var RANK = { current: 0, behind: 1, unverified: 2, 'not-in-brain': 3 }; // column sort on "verdict"
|
|
32
|
+
var RANK_BEHIND = { behind: 0, unverified: 1, 'not-in-brain': 2, current: 3 }; // the "Behind first" view
|
|
33
|
+
|
|
34
|
+
/* ── three views + one search, as pure functions (owner, 2026-09-12: "alphabetically, or
|
|
35
|
+
thematically, or by date"). Published on window.RBScope so the page and its test share them. ── */
|
|
36
|
+
function byName(a, b) {
|
|
37
|
+
return String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
|
|
38
|
+
}
|
|
39
|
+
function newestFirst(a, b) {
|
|
40
|
+
var x = a.ruvChangedAt, y = b.ruvChangedAt;
|
|
41
|
+
if (x == null && y == null) return byName(a, b);
|
|
42
|
+
if (x == null) return 1; // unknown dates sink to the bottom
|
|
43
|
+
if (y == null) return -1;
|
|
44
|
+
return x < y ? 1 : x > y ? -1 : byName(a, b);
|
|
45
|
+
}
|
|
46
|
+
/** Which comparator a named view uses. An unknown view reads as "newest", never throws. */
|
|
47
|
+
function sortFor(view) {
|
|
48
|
+
if (view === 'az') return byName;
|
|
49
|
+
if (view === 'behind') {
|
|
50
|
+
return function (a, b) {
|
|
51
|
+
var ra = RANK_BEHIND[a.bucket], rb = RANK_BEHIND[b.bucket];
|
|
52
|
+
var d = (ra == null ? 9 : ra) - (rb == null ? 9 : rb);
|
|
53
|
+
return d !== 0 ? d : newestFirst(a, b);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return newestFirst;
|
|
57
|
+
}
|
|
58
|
+
/** Does a row answer the query — by name, or by what it does (desc)? An empty query matches everything. */
|
|
59
|
+
function match(row, q) {
|
|
60
|
+
var needle = String(q == null ? '' : q).trim().toLowerCase();
|
|
61
|
+
if (!needle) return true;
|
|
62
|
+
return String(row.name || '').toLowerCase().indexOf(needle) !== -1
|
|
63
|
+
|| String(row.desc || '').toLowerCase().indexOf(needle) !== -1;
|
|
64
|
+
}
|
|
65
|
+
window.RBScope = { match: match, sortFor: sortFor };
|
|
66
|
+
|
|
67
|
+
var VIEW_SORT = { newest: ['ruvChangedAt', 'desc'], az: ['name', 'asc'], behind: ['bucket', 'asc'] };
|
|
68
|
+
var state = { data: null, q: '', view: 'newest', sort: { repos: ['ruvChangedAt', 'desc'], gists: ['ruvChangedAt', 'desc'] } };
|
|
69
|
+
|
|
70
|
+
function cmp(key, dir) {
|
|
71
|
+
var m = dir === 'asc' ? 1 : -1;
|
|
72
|
+
return function (a, b) {
|
|
73
|
+
var x = a[key], y = b[key];
|
|
74
|
+
if (key === 'bucket') { x = RANK[x]; y = RANK[y]; }
|
|
75
|
+
// unknown dates always sink to the bottom, whichever direction is chosen
|
|
76
|
+
if (x == null && y == null) return a.name.localeCompare(b.name);
|
|
77
|
+
if (x == null) return 1;
|
|
78
|
+
if (y == null) return -1;
|
|
79
|
+
if (x < y) return -1 * m;
|
|
80
|
+
if (x > y) return 1 * m;
|
|
81
|
+
return a.name.localeCompare(b.name);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rowHtml(r) {
|
|
86
|
+
var b = BUCKET[r.bucket] || BUCKET.unverified;
|
|
87
|
+
var gap = r.bucket === 'behind' && r.behindDays != null ? '<span class="gap">behind ' + r.behindDays + ' d</span>' : '';
|
|
88
|
+
var d1 = day(r.ruvChangedAt), d2 = day(r.brainReadAt);
|
|
89
|
+
return '<tr>' +
|
|
90
|
+
'<td class="name"><a href="' + esc(r.url) + '" target="_blank" rel="noopener">' + esc(r.name) + '</a>' +
|
|
91
|
+
(r.desc ? '<small class="desc" title="' + esc(r.desc) + '">' + esc(r.desc) + '</small>' : '') + '</td>' +
|
|
92
|
+
'<td class="date' + (d1 ? '' : ' unknown') + '">' + (d1 || 'unknown') + '</td>' +
|
|
93
|
+
'<td class="date' + (d2 ? '' : ' unknown') + '">' + (d2 || (r.bucket === 'not-in-brain' ? '—' : 'unknown')) + '</td>' +
|
|
94
|
+
'<td class="verdict"><span class="badge ' + b.cls + '">' + b.label + '</span>' + gap + '</td>' +
|
|
95
|
+
'<td class="sha" title="brain holds ' + esc(r.brainSha || '—') + ' · upstream ' + esc(r.upstreamSha || '—') + '">' +
|
|
96
|
+
esc((r.brainSha || '—').slice(0, 7)) + ' → ' + esc((r.upstreamSha || '—').slice(0, 7)) + '</td>' +
|
|
97
|
+
'</tr>';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderTable(kind) {
|
|
101
|
+
var rows = state.data[kind] || [];
|
|
102
|
+
var q = state.q.trim().toLowerCase();
|
|
103
|
+
if (q) rows = rows.filter(function (r) { return match(r, q); });
|
|
104
|
+
var s = state.sort[kind];
|
|
105
|
+
// a named view sorts both tables the same way; a column click switches to a per-table sort
|
|
106
|
+
rows = rows.slice().sort(state.view === 'custom' ? cmp(s[0], s[1]) : sortFor(state.view));
|
|
107
|
+
var body = $(kind + '-body');
|
|
108
|
+
var t0 = performance.now();
|
|
109
|
+
body.innerHTML = rows.length ? rows.map(rowHtml).join('') : '<tr><td colspan="5" class="empty">no ' + kind + ' match “' + esc(state.q) + '”</td></tr>';
|
|
110
|
+
$(kind + '-count').textContent = rows.length + (q ? ' of ' + (state.data[kind] || []).length : '');
|
|
111
|
+
var ths = $(kind + '-table').querySelectorAll('thead th[data-key]');
|
|
112
|
+
for (var i = 0; i < ths.length; i++) {
|
|
113
|
+
var k = ths[i].getAttribute('data-key');
|
|
114
|
+
ths[i].setAttribute('aria-sort', k === s[0] ? (s[1] === 'asc' ? 'ascending' : 'descending') : 'none');
|
|
115
|
+
}
|
|
116
|
+
if (window.console && rows.length > 200) console.debug('[scope] rendered', kind, rows.length, 'rows in', Math.round(performance.now() - t0), 'ms');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderBuckets(d) {
|
|
120
|
+
var c = d.counts || {};
|
|
121
|
+
var r = c.repos || {}, g = c.gists || {};
|
|
122
|
+
var tile = function (cls, key, label, desc) {
|
|
123
|
+
var n = (r[key] || 0) + (g[key] || 0);
|
|
124
|
+
return '<div class="bucket ' + cls + '"><div class="n">' + n + '<small>' + (r[key] || 0) + ' repos · ' + (g[key] || 0) + ' gists</small></div>' +
|
|
125
|
+
'<div class="l">' + label + '</div><div class="d">' + desc + '</div></div>';
|
|
126
|
+
};
|
|
127
|
+
$('buckets').innerHTML =
|
|
128
|
+
tile('b-current', 'current', 'in the brain · current', 'The brain holds exactly the commit (or gist revision) that is live upstream.') +
|
|
129
|
+
tile('b-behind', 'behind', 'in the brain · behind', 'rUv changed it after the brain last read it. The gap is shown per row.') +
|
|
130
|
+
tile('b-unverified', 'unverified', 'in the brain · unverified', 'Installed here, but no source commit was recorded, so currency cannot be proven either way.') +
|
|
131
|
+
tile('b-notin', 'notInBrain', 'not in the brain', 'Eligible upstream, no store on this machine.');
|
|
132
|
+
var inel = (r.ineligible || 0) + (g.ineligible || 0);
|
|
133
|
+
var foot = [];
|
|
134
|
+
if (inel) foot.push(inel + ' upstream item' + (inel === 1 ? '' : 's') + ' excluded by policy (forks, archived, empty) — not counted above');
|
|
135
|
+
if (d.installedOutsideCoverage && d.installedOutsideCoverage.length) {
|
|
136
|
+
foot.push(d.installedOutsideCoverage.length + ' store' + (d.installedOutsideCoverage.length === 1 ? '' : 's') + ' installed locally, outside release coverage: <code>' + d.installedOutsideCoverage.map(esc).join('</code>, <code>') + '</code>');
|
|
137
|
+
}
|
|
138
|
+
$('bucket-foot').innerHTML = foot.join(' · ');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderAsOf(d) {
|
|
142
|
+
var el = $('asof');
|
|
143
|
+
var age = d.ageDays == null ? null : Math.round(d.ageDays);
|
|
144
|
+
el.className = 'asof' + (d.stale ? ' is-old' : '');
|
|
145
|
+
el.innerHTML = '<span class="dot"></span>as of <strong>' + esc(day(d.observedAt) || 'unknown') + '</strong>' +
|
|
146
|
+
(age == null ? '' : ' · ' + age + ' day' + (age === 1 ? '' : 's') + ' ago') +
|
|
147
|
+
(d.releaseTag ? ' · brain ' + esc(d.releaseTag) : '') +
|
|
148
|
+
' · ' + (d.installedStoreCount != null ? d.installedStoreCount + ' stores installed' : '');
|
|
149
|
+
$('asof-note').textContent = d.stale
|
|
150
|
+
? 'This is the coverage record that shipped with the installed brain, not a live probe. Repos rUv changed since this date are not reflected until the brain updates.'
|
|
151
|
+
: 'This is the coverage record that shipped with the installed brain.';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function render() {
|
|
155
|
+
var d = state.data;
|
|
156
|
+
renderAsOf(d); renderBuckets(d); renderTable('repos'); renderTable('gists');
|
|
157
|
+
$('foot-root').textContent = d.root || '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function setView(view) {
|
|
161
|
+
state.view = view;
|
|
162
|
+
var buttons = document.querySelectorAll('.scope-views .view');
|
|
163
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
164
|
+
buttons[i].setAttribute('aria-pressed', buttons[i].getAttribute('data-view') === view ? 'true' : 'false');
|
|
165
|
+
}
|
|
166
|
+
if (VIEW_SORT[view]) { state.sort.repos = VIEW_SORT[view].slice(); state.sort.gists = VIEW_SORT[view].slice(); }
|
|
167
|
+
renderTable('repos'); renderTable('gists');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function wire() {
|
|
171
|
+
$('search').addEventListener('input', function (e) { state.q = e.target.value; renderTable('repos'); renderTable('gists'); });
|
|
172
|
+
var buttons = document.querySelectorAll('.scope-views .view');
|
|
173
|
+
for (var b = 0; b < buttons.length; b++) {
|
|
174
|
+
buttons[b].addEventListener('click', function (ev) { setView(ev.currentTarget.getAttribute('data-view')); });
|
|
175
|
+
}
|
|
176
|
+
['repos', 'gists'].forEach(function (kind) {
|
|
177
|
+
var ths = $(kind + '-table').querySelectorAll('thead th[data-key]');
|
|
178
|
+
for (var i = 0; i < ths.length; i++) {
|
|
179
|
+
ths[i].addEventListener('click', function (ev) {
|
|
180
|
+
var k = ev.currentTarget.getAttribute('data-key');
|
|
181
|
+
var s = state.sort[kind];
|
|
182
|
+
state.sort[kind] = [k, s[0] === k && s[1] === 'desc' ? 'asc' : 'desc'];
|
|
183
|
+
state.view = 'custom'; // a column click is a per-table sort; no view button is pressed
|
|
184
|
+
var vb = document.querySelectorAll('.scope-views .view');
|
|
185
|
+
for (var j = 0; j < vb.length; j++) vb[j].setAttribute('aria-pressed', 'false');
|
|
186
|
+
renderTable(kind);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
fetch('/api/scope', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
|
193
|
+
var st = $('state');
|
|
194
|
+
if (!d || d.available === false) {
|
|
195
|
+
st.className = 'state error';
|
|
196
|
+
st.textContent = 'The installed brain has no coverage record on this machine' + (d && d.reason ? ' — ' + d.reason : '') + '. Nothing to list; nothing is being guessed.';
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
st.hidden = true;
|
|
200
|
+
$('content').hidden = false;
|
|
201
|
+
state.data = d;
|
|
202
|
+
wire();
|
|
203
|
+
render();
|
|
204
|
+
}).catch(function (e) {
|
|
205
|
+
var st = $('state');
|
|
206
|
+
st.className = 'state error';
|
|
207
|
+
st.textContent = 'Could not reach /api/scope on the console server: ' + (e && e.message ? e.message : e);
|
|
208
|
+
});
|
|
209
|
+
})();
|
package/console/style.css
CHANGED
|
@@ -2147,3 +2147,29 @@ details.sub .sub-body > *:first-child { margin-top: 4px; }
|
|
|
2147
2147
|
#body-brain .fn-path {
|
|
2148
2148
|
font-family: var(--mono); font-size: 0.86em; color: var(--faint); overflow-wrap: anywhere;
|
|
2149
2149
|
}
|
|
2150
|
+
|
|
2151
|
+
/* ------------------------------------------------------------ inventory card
|
|
2152
|
+
The repository list, by name. Same row language as .cap-row (hairline separators, quiet meta),
|
|
2153
|
+
with a type-to-filter above it because 200+ names need a way in. Long upstream URLs wrap rather
|
|
2154
|
+
than overflow the card. */
|
|
2155
|
+
.inv-tools { display: flex; align-items: center; gap: 12px; margin: 10px 0 8px; flex-wrap: wrap; }
|
|
2156
|
+
.inv-filter {
|
|
2157
|
+
flex: 1 1 220px; min-width: 0; padding: 7px 10px; font: inherit; font-size: 13.5px;
|
|
2158
|
+
color: var(--ink); background: var(--panel-2, transparent);
|
|
2159
|
+
border: 1px solid var(--hair-strong); border-radius: 8px;
|
|
2160
|
+
}
|
|
2161
|
+
.inv-filter:focus { outline: 2px solid var(--cyan); outline-offset: 1px; }
|
|
2162
|
+
.inv-count { font-size: 12.5px; }
|
|
2163
|
+
.inv-list { list-style: none; margin: 0; padding: 0; max-height: 28rem; overflow: auto; border-top: 1px solid var(--hair); }
|
|
2164
|
+
.inv-row {
|
|
2165
|
+
display: grid; grid-template-columns: minmax(10ch, 16ch) auto 1fr auto; align-items: baseline; gap: 6px 12px;
|
|
2166
|
+
padding: 7px 4px; border-bottom: 1px solid var(--hair); font-size: 13.5px;
|
|
2167
|
+
}
|
|
2168
|
+
.inv-row[hidden] { display: none; }
|
|
2169
|
+
.inv-name { font-size: 13px; overflow-wrap: anywhere; }
|
|
2170
|
+
.inv-src { min-width: 0; overflow-wrap: anywhere; font-size: 12.5px; }
|
|
2171
|
+
.inv-meta { font-size: 12px; white-space: nowrap; }
|
|
2172
|
+
@media (max-width: 640px) {
|
|
2173
|
+
.inv-row { grid-template-columns: 1fr auto; }
|
|
2174
|
+
.inv-src, .inv-meta { grid-column: 1 / -1; white-space: normal; }
|
|
2175
|
+
}
|
package/console/tips.html
CHANGED
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
<span class="brand-text">RuvNet Brain <span class="brand-sub">the other 90%</span></span>
|
|
75
75
|
</div>
|
|
76
76
|
<div class="head-right">
|
|
77
|
+
<a class="back-chip mono" href="scope.html">what's in the brain ›</a>
|
|
77
78
|
<a class="back-chip mono" href="./">‹ console</a>
|
|
78
79
|
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Switch color theme">
|
|
79
80
|
<svg class="ic-sun" viewBox="0 0 24 24" aria-hidden="true">
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// kb/corpus-release-identity.mjs — the customer side of a corpus-only release (ADR-086 step 16).
|
|
2
|
+
//
|
|
3
|
+
// TWO IDENTITY DOMAINS, AND WHY CONFLATING THEM COST A NIGHTLY HALF-GIGABYTE.
|
|
4
|
+
//
|
|
5
|
+
// A customer bundle carries a RUNTIME identity: `brainVersion` / `releaseTag` in SOURCE.json,
|
|
6
|
+
// stamped by scripts/build-bundle.mjs:333-334 as the semver of the code release that assembled it.
|
|
7
|
+
// A CORPUS release carries a TRANSPORT identity: the GitHub tag `corpus-sha256-<64 hex>`, which is
|
|
8
|
+
// a content address of the corpus archive and is not a version of anything.
|
|
9
|
+
//
|
|
10
|
+
// kb/forge-update.mjs's isBehind() treated release-tag identity as authoritative with one string
|
|
11
|
+
// inequality. Measured on this fixture before this module existed (4 consecutive `--apply` runs
|
|
12
|
+
// against ONE unchanged corpus release):
|
|
13
|
+
//
|
|
14
|
+
// apply#1: exit=0 zipDownloads=1 behindReported=true
|
|
15
|
+
// apply#2: exit=0 zipDownloads=2 behindReported=true
|
|
16
|
+
// apply#3: exit=0 zipDownloads=3 behindReported=true
|
|
17
|
+
// apply#4: exit=0 zipDownloads=4 behindReported=true
|
|
18
|
+
// check: exit=10 zipDownloads=4 behindReported=true
|
|
19
|
+
//
|
|
20
|
+
// `'corpus-sha256-aaa…' !== 'v4.9.0'` is true, and it stays true after a perfectly successful
|
|
21
|
+
// install, because the landed bundle re-stamps `releaseTag: v4.9.0`. The comparison could never
|
|
22
|
+
// converge. So the transport identity is recorded in its OWN field, `corpusReleaseTag`, written
|
|
23
|
+
// atomically with the installation, and compared only against a corpus tag.
|
|
24
|
+
//
|
|
25
|
+
// THE COMPATIBILITY BOUNDARY (Dual, verbatim): "One releases/latest pointer cannot represent
|
|
26
|
+
// independent newest corpora for multiple incompatible runtimes. Either explicitly support the
|
|
27
|
+
// current approved runtime with safe rejection for older clients, or add version-aware discovery.
|
|
28
|
+
// Never silently install incompatible code."
|
|
29
|
+
//
|
|
30
|
+
// And on how the pin must hold (Dual, verbatim): "Pinning survives only through enforced equality
|
|
31
|
+
// to the approved shipped runtime and its executable hashes. Copying current-main package.json or
|
|
32
|
+
// preserving a version string alone is insufficient."
|
|
33
|
+
//
|
|
34
|
+
// Hence RUNTIME-IDENTITY.json. It is written by bin/install.mjs — the owner-gated code release —
|
|
35
|
+
// and names the sha256 of the executables that release placed into the KB tree. It is NEVER
|
|
36
|
+
// produced by a bundle: build-bundle.mjs cannot see coverage-integrity.mjs (it is behind the
|
|
37
|
+
// updater's dynamic load), which is precisely why that file is the right thing to pin to. The
|
|
38
|
+
// updater re-hashes those bytes at update time; a declared version alone proves nothing and is
|
|
39
|
+
// never sufficient on its own.
|
|
40
|
+
//
|
|
41
|
+
// FAIL-CLOSED, ALWAYS. Missing identity file, unreadable identity file, missing executable,
|
|
42
|
+
// changed executable, or a corpus release built by a different runtime → the corpus release is
|
|
43
|
+
// REFUSED. A client that cannot prove which approved runtime it is running does not get a corpus
|
|
44
|
+
// release; it keeps the bundle it has and is told to re-run the installer.
|
|
45
|
+
|
|
46
|
+
import fs from 'node:fs';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { createHash } from 'node:crypto';
|
|
49
|
+
|
|
50
|
+
/** The corpus transport tag shape published by scripts/corpus-seed-publish.mjs's corpusSeedTag(). */
|
|
51
|
+
export const CORPUS_TAG_PATTERN = /^corpus-sha256-[0-9a-f]{64}$/;
|
|
52
|
+
|
|
53
|
+
/** The installer-written record of which approved runtime this KB belongs to. */
|
|
54
|
+
export const INSTALLED_RUNTIME_FILE = 'RUNTIME-IDENTITY.json';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Executables the runtime pin is bound to, relative to the KB root.
|
|
58
|
+
*
|
|
59
|
+
* `coverage-integrity.mjs` is the trusted coverage validator. It is placed by the installer
|
|
60
|
+
* (bin/install.mjs placeTrustedCoverageValidator) and CANNOT be supplied by a bundle, so its bytes
|
|
61
|
+
* are a real, non-spoofable fingerprint of the approved shipped runtime on this machine. Adding
|
|
62
|
+
* more installer-placed executables here widens the pin; nothing else needs to change.
|
|
63
|
+
*/
|
|
64
|
+
export const RUNTIME_PINNED_EXECUTABLES = Object.freeze(['coverage-integrity.mjs']);
|
|
65
|
+
|
|
66
|
+
/** Where a refused release is remembered — OUTSIDE the governed tree, so an update cannot erase it. */
|
|
67
|
+
export const REJECTED_RELEASE_FILE = '.ruvnet-brain-rejected-release.json';
|
|
68
|
+
|
|
69
|
+
export function isCorpusReleaseTag(tag) {
|
|
70
|
+
return typeof tag === 'string' && CORPUS_TAG_PATTERN.test(tag);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fileIdentity(file) {
|
|
74
|
+
try {
|
|
75
|
+
const bytes = fs.readFileSync(file);
|
|
76
|
+
return { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length };
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function atomicJson(file, value) {
|
|
83
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
84
|
+
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
|
|
85
|
+
fs.renameSync(temp, file);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stamp "this tree belongs to approved runtime X, and here are X's executable hashes" into `kbDir`.
|
|
90
|
+
*
|
|
91
|
+
* Called by the installer immediately after it places those executables, so the declared version and
|
|
92
|
+
* the measured bytes are written from the same moment and cannot disagree.
|
|
93
|
+
*
|
|
94
|
+
* @returns {{written: boolean, path: string, missing: string[]}}
|
|
95
|
+
*/
|
|
96
|
+
export function writeInstalledRuntimeIdentity(kbDir, { brainVersion }) {
|
|
97
|
+
if (!brainVersion || typeof brainVersion !== 'string') {
|
|
98
|
+
throw new Error('writeInstalledRuntimeIdentity requires the approved runtime brainVersion');
|
|
99
|
+
}
|
|
100
|
+
const executables = {};
|
|
101
|
+
const missing = [];
|
|
102
|
+
for (const relative of RUNTIME_PINNED_EXECUTABLES) {
|
|
103
|
+
const identity = fileIdentity(path.join(kbDir, relative));
|
|
104
|
+
if (!identity) { missing.push(relative); continue; }
|
|
105
|
+
executables[relative] = identity;
|
|
106
|
+
}
|
|
107
|
+
const target = path.join(kbDir, INSTALLED_RUNTIME_FILE);
|
|
108
|
+
// A pin naming an executable that is not there would be a pin to nothing. Refuse to write one.
|
|
109
|
+
if (missing.length) return { written: false, path: target, missing };
|
|
110
|
+
atomicJson(target, {
|
|
111
|
+
schemaVersion: 1,
|
|
112
|
+
kind: 'ruvnet-brain-installed-runtime',
|
|
113
|
+
brainVersion,
|
|
114
|
+
stampedUtc: new Date().toISOString(),
|
|
115
|
+
executables,
|
|
116
|
+
});
|
|
117
|
+
return { written: true, path: target, missing };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read the installed approved runtime and RE-HASH every executable it pins.
|
|
122
|
+
*
|
|
123
|
+
* The declared `brainVersion` is only ever returned alongside proof that the bytes it was written
|
|
124
|
+
* against are still the bytes on disk — that is the whole difference between an enforced pin and a
|
|
125
|
+
* copied version string.
|
|
126
|
+
*
|
|
127
|
+
* @returns {{ok: boolean, brainVersion: string|null, reason: string|null}}
|
|
128
|
+
*/
|
|
129
|
+
export function readInstalledRuntime(kbDir) {
|
|
130
|
+
const file = path.join(kbDir, INSTALLED_RUNTIME_FILE);
|
|
131
|
+
let doc;
|
|
132
|
+
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { ok: false, brainVersion: null,
|
|
135
|
+
reason: `no installed runtime identity at ${INSTALLED_RUNTIME_FILE} (${error.code === 'ENOENT' ? 'absent' : error.message})` };
|
|
136
|
+
}
|
|
137
|
+
if (doc?.schemaVersion !== 1 || doc?.kind !== 'ruvnet-brain-installed-runtime'
|
|
138
|
+
|| typeof doc.brainVersion !== 'string' || !doc.brainVersion || !doc.executables) {
|
|
139
|
+
return { ok: false, brainVersion: null, reason: `${INSTALLED_RUNTIME_FILE} is not a readable installed-runtime record` };
|
|
140
|
+
}
|
|
141
|
+
const declared = Object.keys(doc.executables).sort();
|
|
142
|
+
const expected = [...RUNTIME_PINNED_EXECUTABLES].sort();
|
|
143
|
+
if (declared.join('\0') !== expected.join('\0')) {
|
|
144
|
+
return { ok: false, brainVersion: null,
|
|
145
|
+
reason: `${INSTALLED_RUNTIME_FILE} pins [${declared.join(', ')}], this runtime requires [${expected.join(', ')}]` };
|
|
146
|
+
}
|
|
147
|
+
for (const relative of expected) {
|
|
148
|
+
const measured = fileIdentity(path.join(kbDir, relative));
|
|
149
|
+
const pinned = doc.executables[relative];
|
|
150
|
+
if (!measured) {
|
|
151
|
+
return { ok: false, brainVersion: null, reason: `pinned runtime executable ${relative} is missing` };
|
|
152
|
+
}
|
|
153
|
+
if (measured.sha256 !== pinned?.sha256 || measured.bytes !== pinned?.bytes) {
|
|
154
|
+
return { ok: false, brainVersion: null,
|
|
155
|
+
reason: `pinned runtime executable ${relative} does not match its approved bytes `
|
|
156
|
+
+ `(approved ${String(pinned?.sha256).slice(0, 12)}…/${pinned?.bytes}B, on disk ${measured.sha256.slice(0, 12)}…/${measured.bytes}B)` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, brainVersion: doc.brainVersion, reason: null };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide whether this client may install the release now staged/offered.
|
|
164
|
+
*
|
|
165
|
+
* `offeredRuntimeVersion` is the brainVersion the candidate bundle declares — read from a STAGED
|
|
166
|
+
* tree that has already passed signature verification, never from the release payload's prose.
|
|
167
|
+
*
|
|
168
|
+
* @returns {{ok: boolean, reason: string|null, installedRuntime: string|null}}
|
|
169
|
+
*/
|
|
170
|
+
export function assertCorpusReleaseCompatible({ kbDir, offeredRuntimeVersion = null }) {
|
|
171
|
+
const runtime = readInstalledRuntime(kbDir);
|
|
172
|
+
if (!runtime.ok) return { ok: false, reason: runtime.reason, installedRuntime: null };
|
|
173
|
+
if (offeredRuntimeVersion == null) return { ok: true, reason: null, installedRuntime: runtime.brainVersion };
|
|
174
|
+
if (offeredRuntimeVersion !== runtime.brainVersion) {
|
|
175
|
+
return { ok: false, installedRuntime: runtime.brainVersion,
|
|
176
|
+
reason: `this corpus release was built by runtime ${offeredRuntimeVersion}; `
|
|
177
|
+
+ `this brain runs approved runtime ${runtime.brainVersion}` };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, reason: null, installedRuntime: runtime.brainVersion };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record the authenticated transport identity of the bytes now being installed, IN the tree being
|
|
184
|
+
* installed — so promotion carries it or nothing is promoted. The runtime identity fields
|
|
185
|
+
* (`brainVersion` / `releaseTag`) are never touched: they describe the code that built the bundle,
|
|
186
|
+
* and a corpus tag is not a version of that code.
|
|
187
|
+
*/
|
|
188
|
+
export function recordCorpusTransportIdentity(treeDir, { releaseTag }) {
|
|
189
|
+
const file = path.join(treeDir, 'SOURCE.json');
|
|
190
|
+
const source = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
191
|
+
// STRICTLY IDEMPOTENT, AND THAT IS NOT A STYLE CHOICE. A promoted tree's byte identity is what
|
|
192
|
+
// the storage transaction compares to decide `noop` vs `applied` — the distinction issue #106 and
|
|
193
|
+
// #108 were fought over. Rewriting SOURCE.json to set a field to the value it already holds (or
|
|
194
|
+
// to delete keys that were never there) re-serializes the file and changes its bytes, which turns
|
|
195
|
+
// a genuine no-op into a spurious "applied" and strands a rollback copy. So: touch the file only
|
|
196
|
+
// when a field actually changes. For the same reason there is no install timestamp here — a
|
|
197
|
+
// clock-valued field would differ on every run and could never be byte-identical. The tag IS the
|
|
198
|
+
// identity; `builtUtc` already carries the time.
|
|
199
|
+
if (isCorpusReleaseTag(releaseTag)) {
|
|
200
|
+
if (source.corpusReleaseTag === releaseTag) return source;
|
|
201
|
+
source.corpusReleaseTag = releaseTag;
|
|
202
|
+
} else {
|
|
203
|
+
// An ordinary code release supersedes whatever corpus generation preceded it: its bundle IS the
|
|
204
|
+
// corpus. Leaving a stale corpus tag behind would make the next check compare against a corpus
|
|
205
|
+
// this tree no longer holds.
|
|
206
|
+
if (!Object.hasOwn(source, 'corpusReleaseTag')) return source;
|
|
207
|
+
delete source.corpusReleaseTag;
|
|
208
|
+
}
|
|
209
|
+
atomicJson(file, source);
|
|
210
|
+
return source;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── the anti-loop ledger ────────────────────────────────────────────────────────────────────────
|
|
214
|
+
// A refusal that is rediscovered every night is a download loop with extra steps. The refused tag
|
|
215
|
+
// is remembered next to the KB (never inside it, where an update would replace it), so the very
|
|
216
|
+
// next run refuses BEFORE fetching a byte. It is cleared the moment a different release is offered.
|
|
217
|
+
|
|
218
|
+
export function rejectedReleasePath(kbDir) {
|
|
219
|
+
return path.join(path.dirname(path.resolve(kbDir)), REJECTED_RELEASE_FILE);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function readRejectedRelease(kbDir) {
|
|
223
|
+
try {
|
|
224
|
+
const doc = JSON.parse(fs.readFileSync(rejectedReleasePath(kbDir), 'utf8'));
|
|
225
|
+
return typeof doc?.tag === 'string' && doc.tag ? doc : null;
|
|
226
|
+
} catch { return null; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function writeRejectedRelease(kbDir, { tag, reason, installedRuntime = null }) {
|
|
230
|
+
try {
|
|
231
|
+
atomicJson(rejectedReleasePath(kbDir), { schemaVersion: 1, kind: 'ruvnet-brain-rejected-release',
|
|
232
|
+
tag, reason, installedRuntime, rejectedUtc: new Date().toISOString() });
|
|
233
|
+
return true;
|
|
234
|
+
} catch { return false; } // never let bookkeeping mask the refusal itself
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function clearRejectedRelease(kbDir) {
|
|
238
|
+
try { fs.rmSync(rejectedReleasePath(kbDir), { force: true }); return true; } catch { return false; }
|
|
239
|
+
}
|