ruvnet-brain 4.7.10 → 4.7.12
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 +23 -3
- package/data/codex-hook-wrapper-history.json +146 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-wrapper-history.mjs +127 -0
- package/scripts/corpus-canary.mjs +18 -4
- package/scripts/corpus-coverage-sidecar.mjs +3 -1
- package/scripts/corpus-next-seed.mjs +40 -10
- package/scripts/corpus-reconcile.mjs +7 -15
- package/scripts/corpus-store-failure.mjs +26 -27
- package/scripts/corpus-watchdog.mjs +25 -3
- package/scripts/rehearse-corpus-pipeline.mjs +3 -18
- package/scripts/release.mjs +9 -14
- package/scripts/single-source-check.mjs +14 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
|
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
|
|
|
255
255
|
does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
|
|
256
256
|
before treating an installation as current.
|
|
257
257
|
|
|
258
|
-
The **4.6.0 published-corpus baseline** is **
|
|
258
|
+
The **4.6.0 published-corpus baseline** is **203 public stores · 164,393 public source chunks**.
|
|
259
259
|
Those are bundle census values, not a claim that every upstream repository is fresh today.
|
|
260
260
|
Later corpus publication may advance the knowledge generation independently of code changes.
|
|
261
261
|
|
package/bin/install.mjs
CHANGED
|
@@ -1770,9 +1770,29 @@ function legacyBrainWrapperOwnership(wrapperPath, home) {
|
|
|
1770
1770
|
if (installed && shippedByRetainedGeneration(installed, path.join(home, '.cache', 'ruvnet-brain', 'versions'))) {
|
|
1771
1771
|
return { state: 'prior-generation' };
|
|
1772
1772
|
}
|
|
1773
|
+
// A PRUNED generation's bridge (owner's machine, 2026-10-17): the bytes an EARLIER shipped release wrote,
|
|
1774
|
+
// whose versions/<v>/ copy is gone (pruned, or written by a release that predates the Spine). The proof
|
|
1775
|
+
// is the committed history of every wrapper each release tag carried, derived from git by
|
|
1776
|
+
// scripts/codex-hook-wrapper-history.mjs and checked against git in the unit suite. Bytes only: the
|
|
1777
|
+
// path, link, inode and uid checks above have already passed by the time this is consulted.
|
|
1778
|
+
const past = installed && shippedByPastRelease(installed);
|
|
1779
|
+
if (past) return { state: 'prior-release', release: past.tags[past.tags.length - 1] };
|
|
1773
1780
|
return { state: 'conflict', reason: 'existing bridge differs from the shipped Brain source' };
|
|
1774
1781
|
}
|
|
1775
1782
|
|
|
1783
|
+
// Test seam only: RUVNET_WRAPPER_HISTORY_FILE points the proof at another history file, so the unit suite
|
|
1784
|
+
// can hand the installer a malformed or hostile history without touching the committed data file.
|
|
1785
|
+
const WRAPPER_HISTORY_FILE = path.join(REPO_ROOT, 'data', 'codex-hook-wrapper-history.json');
|
|
1786
|
+
function shippedByPastRelease(bytes, historyFile = process.env.RUVNET_WRAPPER_HISTORY_FILE || WRAPPER_HISTORY_FILE) {
|
|
1787
|
+
let history;
|
|
1788
|
+
try { history = JSON.parse(fs.readFileSync(historyFile, 'utf8')); } catch { return null; }
|
|
1789
|
+
if (history?.schemaVersion !== 1 || history.kind !== 'ruvnet-brain-codex-hook-wrapper-history' || !Array.isArray(history.wrappers)) return null;
|
|
1790
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
1791
|
+
const hit = history.wrappers.find((row) => row?.sha256 === sha256 && row.bytes === bytes.length
|
|
1792
|
+
&& Array.isArray(row.tags) && row.tags.length > 0 && row.tags.every((tag) => /^v\d+\.\d+\.\d+$/.test(tag)));
|
|
1793
|
+
return hit || null;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1776
1796
|
function shippedByRetainedGeneration(bytes, versionsDir) {
|
|
1777
1797
|
const ownedDir = (dir) => {
|
|
1778
1798
|
try {
|
|
@@ -1907,7 +1927,7 @@ export function wireCodexHost({
|
|
|
1907
1927
|
const currentOwnership = legacyBrainWrapperOwnership(hookWrapperPath, path.dirname(codexDir));
|
|
1908
1928
|
if (currentOwnership.state === 'conflict') throw new Error(currentOwnership.reason);
|
|
1909
1929
|
hookRetirement = retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
|
|
1910
|
-
if (currentOwnership.state === 'prior-generation') hookWrapperBackup = backupPriorWrapper(hookWrapperPath);
|
|
1930
|
+
if (currentOwnership.state === 'prior-generation' || currentOwnership.state === 'prior-release') hookWrapperBackup = backupPriorWrapper(hookWrapperPath);
|
|
1911
1931
|
atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
|
|
1912
1932
|
} catch (error) {
|
|
1913
1933
|
if (announce) warn(`could not install the Codex hook bridge at ${hookWrapperPath}: ${error.message}`);
|
|
@@ -2413,8 +2433,8 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
|
|
|
2413
2433
|
let removed = 0;
|
|
2414
2434
|
const changedFiles = [];
|
|
2415
2435
|
const conflicts = [];
|
|
2416
|
-
// A bridge a retained Brain generation shipped is as much the Brain's as the new source.
|
|
2417
|
-
const verifiedWrapper = ['verified', 'prior-generation']
|
|
2436
|
+
// A bridge a retained Brain generation, or any earlier shipped release, wrote is as much the Brain's as the new source.
|
|
2437
|
+
const verifiedWrapper = ['verified', 'prior-generation', 'prior-release']
|
|
2418
2438
|
.includes(legacyBrainWrapperOwnership(wrapperPath, path.dirname(codexDir)).state);
|
|
2419
2439
|
const tokensOf = (command) => (command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [])
|
|
2420
2440
|
.map((token) => token.replace(/^["']|["']$/g, ''));
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"kind": "ruvnet-brain-codex-hook-wrapper-history",
|
|
4
|
+
"source": "plugin/scripts/codex-hook-wrapper.mjs",
|
|
5
|
+
"releaseTags": 86,
|
|
6
|
+
"wrappers": [
|
|
7
|
+
{
|
|
8
|
+
"sha256": "f4a3159e06e8d7783be4f14070ddc34d298ace6ab120d92456d987b4d451f0f7",
|
|
9
|
+
"bytes": 1309,
|
|
10
|
+
"tags": [
|
|
11
|
+
"v4.0.1",
|
|
12
|
+
"v4.0.2",
|
|
13
|
+
"v4.0.3",
|
|
14
|
+
"v4.0.4",
|
|
15
|
+
"v4.0.5"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"sha256": "37accf7f90aa61e538cb2ef1d162e95636947a0f177c6194ebe938a477c5642c",
|
|
20
|
+
"bytes": 2392,
|
|
21
|
+
"tags": [
|
|
22
|
+
"v4.0.6",
|
|
23
|
+
"v4.0.7"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"sha256": "53b1b27b98ba7d20777a2b2877ffb5d0348f5b8e69e8cc24215d2e0fb6e50939",
|
|
28
|
+
"bytes": 4904,
|
|
29
|
+
"tags": [
|
|
30
|
+
"v4.0.8"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"sha256": "48599192eff873bc95d7f849e42ae3d4fcd2d218e4c98caede65e5ddf9333d67",
|
|
35
|
+
"bytes": 4991,
|
|
36
|
+
"tags": [
|
|
37
|
+
"v4.0.24",
|
|
38
|
+
"v4.0.28",
|
|
39
|
+
"v4.0.35",
|
|
40
|
+
"v4.0.36"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"sha256": "d29208f812a90d1507f3c928bd22dd37569d1f13d45fb7ce09592fc8e2dd6bd5",
|
|
45
|
+
"bytes": 8692,
|
|
46
|
+
"tags": [
|
|
47
|
+
"v4.3.1",
|
|
48
|
+
"v4.3.8",
|
|
49
|
+
"v4.3.9"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"sha256": "16cbfd7083d50ad36ac187096e8b1dea7d5784ce5083ec436d4e54e2ce4d74a5",
|
|
54
|
+
"bytes": 10151,
|
|
55
|
+
"tags": [
|
|
56
|
+
"v4.3.10",
|
|
57
|
+
"v4.3.11",
|
|
58
|
+
"v4.3.14",
|
|
59
|
+
"v4.3.15",
|
|
60
|
+
"v4.3.16",
|
|
61
|
+
"v4.3.17",
|
|
62
|
+
"v4.3.18",
|
|
63
|
+
"v4.3.19",
|
|
64
|
+
"v4.3.20",
|
|
65
|
+
"v4.3.21",
|
|
66
|
+
"v4.3.22",
|
|
67
|
+
"v4.3.25",
|
|
68
|
+
"v4.3.26",
|
|
69
|
+
"v4.3.27",
|
|
70
|
+
"v4.3.28",
|
|
71
|
+
"v4.3.29",
|
|
72
|
+
"v4.3.30",
|
|
73
|
+
"v4.3.31",
|
|
74
|
+
"v4.3.32",
|
|
75
|
+
"v4.3.33",
|
|
76
|
+
"v4.3.34",
|
|
77
|
+
"v4.3.35",
|
|
78
|
+
"v4.3.36",
|
|
79
|
+
"v4.3.37",
|
|
80
|
+
"v4.3.38"
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"sha256": "801d813bd76a06a85f6874a69d548d455a23d9c0d142b2f84712e37c5dd61566",
|
|
85
|
+
"bytes": 10441,
|
|
86
|
+
"tags": [
|
|
87
|
+
"v4.3.39",
|
|
88
|
+
"v4.3.40"
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"sha256": "d3ee284722aadd9699ba7b7ece624cd59c5dd4a773cd2d7300340d1a662d0a15",
|
|
93
|
+
"bytes": 10924,
|
|
94
|
+
"tags": [
|
|
95
|
+
"v4.4.0",
|
|
96
|
+
"v4.4.1",
|
|
97
|
+
"v4.5.0",
|
|
98
|
+
"v4.5.1",
|
|
99
|
+
"v4.5.2",
|
|
100
|
+
"v4.5.3",
|
|
101
|
+
"v4.5.4",
|
|
102
|
+
"v4.5.5",
|
|
103
|
+
"v4.5.6",
|
|
104
|
+
"v4.5.7",
|
|
105
|
+
"v4.5.8",
|
|
106
|
+
"v4.5.9",
|
|
107
|
+
"v4.5.10",
|
|
108
|
+
"v4.5.11",
|
|
109
|
+
"v4.5.12",
|
|
110
|
+
"v4.5.13",
|
|
111
|
+
"v4.5.14",
|
|
112
|
+
"v4.5.15",
|
|
113
|
+
"v4.5.16"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"sha256": "ea953c7329ea7c99ad681ae216a2bc537ff9ae3d75a1bb8b5ab69706df9ea6d3",
|
|
118
|
+
"bytes": 12180,
|
|
119
|
+
"tags": [
|
|
120
|
+
"v4.5.17",
|
|
121
|
+
"v4.6.0",
|
|
122
|
+
"v4.6.1",
|
|
123
|
+
"v4.6.2",
|
|
124
|
+
"v4.6.3",
|
|
125
|
+
"v4.6.4"
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"sha256": "d3c650ec9f211629d8a8aadb4d4dd0016d9e3ee0cc7a989c86f5629fefef664d",
|
|
130
|
+
"bytes": 12940,
|
|
131
|
+
"tags": [
|
|
132
|
+
"v4.7.0",
|
|
133
|
+
"v4.7.1",
|
|
134
|
+
"v4.7.2",
|
|
135
|
+
"v4.7.3",
|
|
136
|
+
"v4.7.4",
|
|
137
|
+
"v4.7.5",
|
|
138
|
+
"v4.7.6",
|
|
139
|
+
"v4.7.7",
|
|
140
|
+
"v4.7.8",
|
|
141
|
+
"v4.7.9",
|
|
142
|
+
"v4.7.10"
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.12",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"config/model-router/",
|
|
102
102
|
"config/practical-rule-catalog.json",
|
|
103
103
|
"tri-smart-skill/",
|
|
104
|
-
"data/model-catalog.json",
|
|
104
|
+
"data/model-catalog.json","data/codex-hook-wrapper-history.json",
|
|
105
105
|
"scripts/",
|
|
106
106
|
"kb/verify-citation.mjs",
|
|
107
107
|
"kb/retrieval-result.mjs",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.12",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -189,7 +189,8 @@ export function assembleCodeReleaseCorpus({
|
|
|
189
189
|
}
|
|
190
190
|
if (verified.degraded.carried.length || verified.degraded.missing.length) {
|
|
191
191
|
fail(`generation ${descriptor.tag} is degraded (${verified.degraded.carried.length} carried, `
|
|
192
|
-
+ `${verified.degraded.missing.length} missing); a code release
|
|
192
|
+
+ `${verified.degraded.missing.length} missing); a code release ships only an all-CURRENT corpus -- its install `
|
|
193
|
+
+ 'verification (scripts/public-verification-aggregate.mjs) requires every eligible store CURRENT');
|
|
193
194
|
}
|
|
194
195
|
const before = treeDigest(assets);
|
|
195
196
|
// build-bundle reads the sealed coverage from the ONE canonical path (<root>/data/source-coverage.json),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/codex-hook-wrapper-history.mjs — every byte-exact Codex hook bridge the Brain has ever shipped.
|
|
3
|
+
//
|
|
4
|
+
// WHY (owner's machine, 2026-10-17). bin/install.mjs trusts an existing ~/.cache/ruvnet-brain/codex-hook.mjs
|
|
5
|
+
// only when it equals the NEW source or a wrapper in a RETAINED Stable Spine generation (versions/<v>/).
|
|
6
|
+
// A bridge written by an older release whose generation has since been pruned (for example the 4.5.2
|
|
7
|
+
// wrapper, sha256 d3ee284722aa…, written while stale 4.5.2 code ran) is still the Brain's own bytes, but
|
|
8
|
+
// nothing on disk proves it any more, so every update reported hook-wrapper-ownership-conflict and failed
|
|
9
|
+
// host convergence night after night. The proof that was missing is HISTORICAL: the set of wrapper bytes
|
|
10
|
+
// each shipped tag carried. That set is derived here from git, committed as data/codex-hook-wrapper-history.json
|
|
11
|
+
// (shipped in the npm package), and checked against git in the unit suite so it cannot drift.
|
|
12
|
+
//
|
|
13
|
+
// It proves only identity of BYTES. Links, hard links, another uid, a foreign path and edited bytes stay
|
|
14
|
+
// conflicts: those checks run first in legacyBrainWrapperOwnership and are untouched.
|
|
15
|
+
//
|
|
16
|
+
// node scripts/codex-hook-wrapper-history.mjs --write # regenerate data/codex-hook-wrapper-history.json
|
|
17
|
+
// node scripts/codex-hook-wrapper-history.mjs --check # exit 1 when the committed file differs from git
|
|
18
|
+
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
export const HISTORY_FILE = path.join('data', 'codex-hook-wrapper-history.json');
|
|
27
|
+
export const HISTORY_KIND = 'ruvnet-brain-codex-hook-wrapper-history';
|
|
28
|
+
export const WRAPPER_SOURCE = 'plugin/scripts/codex-hook-wrapper.mjs';
|
|
29
|
+
const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
|
|
30
|
+
|
|
31
|
+
const semver = (tag) => tag.slice(1).split('.').map(Number);
|
|
32
|
+
const byVersion = (a, b) => { const [x, y] = [semver(a), semver(b)]; return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; };
|
|
33
|
+
|
|
34
|
+
function git(root, args) {
|
|
35
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Release tags (vX.Y.Z) in this checkout, ascending. Empty when git or the tags are unavailable. */
|
|
39
|
+
export function releaseTags(root = ROOT) {
|
|
40
|
+
try {
|
|
41
|
+
return git(root, ['tag', '-l', 'v*']).split('\n').map((line) => line.trim()).filter((tag) => RELEASE_TAG.test(tag)).sort(byVersion);
|
|
42
|
+
} catch { return []; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** { sha256, bytes, tags } for every distinct wrapper the release tags carried, ordered by first shipping tag. */
|
|
46
|
+
export function deriveWrapperHistory(root = ROOT) {
|
|
47
|
+
const tags = releaseTags(root);
|
|
48
|
+
const wrappers = new Map();
|
|
49
|
+
for (const tag of tags) {
|
|
50
|
+
let bytes;
|
|
51
|
+
try { bytes = execFileSync('git', ['show', `${tag}:${WRAPPER_SOURCE}`], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 }); }
|
|
52
|
+
catch { continue; } // a tag that predates the wrapper shipped no bridge
|
|
53
|
+
const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
54
|
+
if (!wrappers.has(sha256)) wrappers.set(sha256, { sha256, bytes: bytes.length, tags: [] });
|
|
55
|
+
wrappers.get(sha256).tags.push(tag);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
schemaVersion: 1,
|
|
59
|
+
kind: HISTORY_KIND,
|
|
60
|
+
source: WRAPPER_SOURCE,
|
|
61
|
+
releaseTags: tags.length,
|
|
62
|
+
wrappers: [...wrappers.values()],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readWrapperHistory(root = ROOT) {
|
|
67
|
+
const history = JSON.parse(fs.readFileSync(path.join(root, HISTORY_FILE), 'utf8'));
|
|
68
|
+
if (history?.schemaVersion !== 1 || history.kind !== HISTORY_KIND || !Array.isArray(history.wrappers)) {
|
|
69
|
+
throw new Error(`${HISTORY_FILE} is not a schema-1 ${HISTORY_KIND}`);
|
|
70
|
+
}
|
|
71
|
+
return history;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How the committed history differs from git: `missing` = wrappers a release tag shipped that the file lacks
|
|
76
|
+
* (an upgrade of those bytes would be a conflict again); `extra` = rows in the file no release tag shipped
|
|
77
|
+
* (bytes the installer would trust that git never did -- a hand-added row is exactly the forgery this must name).
|
|
78
|
+
*/
|
|
79
|
+
export function historyDrift(committed, derived) {
|
|
80
|
+
const key = (row) => `${row.sha256}:${row.bytes}`;
|
|
81
|
+
const known = new Set((committed.wrappers || []).map(key));
|
|
82
|
+
const shipped = new Set(derived.wrappers.map(key));
|
|
83
|
+
return {
|
|
84
|
+
missing: derived.wrappers.filter((row) => !known.has(key(row))),
|
|
85
|
+
extra: (committed.wrappers || []).filter((row) => !shipped.has(key(row))),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Kept for callers that only ask the one-sided question. */
|
|
89
|
+
export function missingWrappers(committed, derived) { return historyDrift(committed, derived).missing; }
|
|
90
|
+
export function describeDrift({ missing, extra }) {
|
|
91
|
+
const name = (row) => `${String(row.sha256 || '').slice(0, 12)} (${row.tags?.[0] ?? 'no tag'})`;
|
|
92
|
+
return [missing.length ? `missing ${missing.map(name).join(', ')}` : '', extra.length ? `not shipped by any release tag: ${extra.map(name).join(', ')}` : '']
|
|
93
|
+
.filter(Boolean).join('; ');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function main(argv = process.argv.slice(2)) {
|
|
97
|
+
const derived = deriveWrapperHistory();
|
|
98
|
+
if (!derived.releaseTags) {
|
|
99
|
+
console.error('[wrapper-history] no vX.Y.Z tags are visible to git here; nothing to derive');
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const text = `${JSON.stringify(derived, null, 2)}\n`;
|
|
103
|
+
const file = path.join(ROOT, HISTORY_FILE);
|
|
104
|
+
if (argv.includes('--write')) {
|
|
105
|
+
fs.writeFileSync(file, text);
|
|
106
|
+
console.log(`wrote ${HISTORY_FILE}: ${derived.wrappers.length} distinct wrapper(s) across ${derived.releaseTags} release tag(s)`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (argv.includes('--check')) {
|
|
110
|
+
// The BYTES are the contract; the tag lists are provenance. A new release that ships an unchanged wrapper
|
|
111
|
+
// adds a tag without adding a wrapper and must not fail this check at the next checkout.
|
|
112
|
+
let drift;
|
|
113
|
+
try { drift = historyDrift(readWrapperHistory(ROOT), derived); } catch (error) { console.error(`[wrapper-history] ${error.message}`); return 1; }
|
|
114
|
+
if (!drift.missing.length && !drift.extra.length) {
|
|
115
|
+
console.log(`${HISTORY_FILE} carries exactly the wrappers git's release tags shipped (${derived.wrappers.length} wrapper(s), ${derived.releaseTags} tag(s))`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
console.error(`${HISTORY_FILE} differs from git: ${describeDrift(drift)}; run: node scripts/codex-hook-wrapper-history.mjs --write`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
console.error('usage: codex-hook-wrapper-history.mjs --write | --check');
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url))) {
|
|
126
|
+
process.exitCode = main();
|
|
127
|
+
}
|
|
@@ -46,6 +46,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
46
46
|
import { addPrivateStore, resolveRuntime, resultFromRefreshReceipt, runInstallerDoor } from './customer-seams.mjs';
|
|
47
47
|
import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
|
|
48
48
|
import { inventoryFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
49
|
+
import { eligibleRepositoryStanding } from '../plugin/scripts/coverage-integrity.mjs';
|
|
49
50
|
|
|
50
51
|
export const PUBLIC_API = 'https://api.github.com';
|
|
51
52
|
export const FRESHNESS_LIMIT_MS = 48 * 3_600_000;
|
|
@@ -126,6 +127,13 @@ function coverageRows(file) {
|
|
|
126
127
|
* 2026-09-30), so a "moved store must be ingested within 48h of now" rule would wrongly refuse a
|
|
127
128
|
* perfectly fresh generation about three nights after every code release.
|
|
128
129
|
* MISSING/INELIGIBLE rows are the coverage validator's business (it already ran), not a freshness question.
|
|
130
|
+
*
|
|
131
|
+
* CARRIED STORES (ADR-0091 D5, decided 2026-10-17): a repository row the shipped validator accepts as
|
|
132
|
+
* `shipped` WITH a `carry` record -- STALE, bytes re-hashed against the ledger, `carry.carriedSourceCommit`
|
|
133
|
+
* equal to the shipped commit and `carry.missedUpstream` equal to the observed upstream -- is not a stale
|
|
134
|
+
* store: the generation names the miss and keeps the last verified bytes on purpose. It is reported here by
|
|
135
|
+
* name, never silently. A row whose shipped commit differs from its upstream WITHOUT that record is still
|
|
136
|
+
* stale and still fails: that is the shape an unrecorded miss has.
|
|
129
137
|
*/
|
|
130
138
|
export function storeFreshness({ after, before = null, now }) {
|
|
131
139
|
if (!after) return { ok: false, detail: 'CORPUS-COVERAGE.json is missing from the installed tree' };
|
|
@@ -136,19 +144,25 @@ export function storeFreshness({ after, before = null, now }) {
|
|
|
136
144
|
if (age > FRESHNESS_LIMIT_MS || age < 0) problems.push(`upstream observed ${(age / 3_600_000).toFixed(1)}h ago (limit 48h)`);
|
|
137
145
|
const priorCommit = new Map((before?.rows || []).map((row) => [row.key, row.artifact?.sourceCommit || null]));
|
|
138
146
|
const stale = [];
|
|
147
|
+
const carried = [];
|
|
139
148
|
let moved = 0;
|
|
140
149
|
for (const row of after.rows) {
|
|
141
150
|
if (row.status === 'INELIGIBLE') continue;
|
|
142
151
|
const upstream = row.upstream?.sha || null;
|
|
143
152
|
const built = row.artifact?.sourceCommit || null;
|
|
144
153
|
if (!upstream || !built) continue;
|
|
145
|
-
if (upstream !== built) {
|
|
154
|
+
if (upstream !== built) {
|
|
155
|
+
if (row.kind === 'repository' && row.carry && eligibleRepositoryStanding(row) === 'shipped') carried.push(row.artifact?.store || row.key);
|
|
156
|
+
else stale.push(row.artifact?.store || row.key);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
146
159
|
if (before && priorCommit.get(row.key) !== upstream) moved += 1;
|
|
147
160
|
}
|
|
148
|
-
if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream: ${stale.slice(0, 10).join(', ')}`);
|
|
161
|
+
if (stale.length) problems.push(`${stale.length} store(s) built from a commit older than their observed upstream with no recorded carry: ${stale.slice(0, 10).join(', ')}`);
|
|
162
|
+
const carriedDetail = carried.length ? `${carried.length} carried at their last verified bytes with a recorded reason: ${carried.slice(0, 10).join(', ')}` : '0 carried';
|
|
149
163
|
return problems.length
|
|
150
|
-
? { ok: false, detail: problems.join('; ') }
|
|
151
|
-
: { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale; ${before ? `${moved} moved since the installed generation, all at their observed upstream commit` : 'no prior coverage to diff against'}` };
|
|
164
|
+
? { ok: false, detail: `${problems.join('; ')}; ${carriedDetail}` }
|
|
165
|
+
: { ok: true, detail: `observed ${(age / 3_600_000).toFixed(1)}h ago; ${after.rows.length} row(s), 0 stale, ${carriedDetail}; ${before ? `${moved} moved since the installed generation, all at their observed upstream commit` : 'no prior coverage to diff against'}` };
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
/**
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
// and the receipt binds none, so nothing downstream of preparation could tell a degraded generation
|
|
12
12
|
// (carried / missing stores) from a fully current one. The coverage lived only in a 14-day workflow
|
|
13
13
|
// artifact. D6's code-release path needs the generation's sealed coverage to assemble single-pass,
|
|
14
|
-
//
|
|
14
|
+
// the publisher (release.mjs) binds it to the receipt and names a degraded generation's stores from it,
|
|
15
|
+
// and the watchdog's 7-day carry rule and corpus-next-seed's code-release refusal of a degraded seed
|
|
16
|
+
// both read it. (The former ADR-0091 D10 soak gate also read it; that gate was removed 2026-10-17.)
|
|
15
17
|
//
|
|
16
18
|
// CORPUS-COVERAGE.json -- the sealed ruvnet-brain-corpus-coverage ledger, exact bytes.
|
|
17
19
|
// coverage-receipt.json -- { generationTag, archiveSha256, archiveBytes, coverageSha256, ... }.
|
|
@@ -148,8 +148,8 @@ const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(fi
|
|
|
148
148
|
function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCoverage = false }) {
|
|
149
149
|
// `indeterminate` marks a rejection that says nothing about the release itself -- the network or
|
|
150
150
|
// `gh` failed. A publish-time re-check (ADR-0091 D6.6) must not read one as "no newer generation".
|
|
151
|
-
const reject = (reason, { indeterminate = false } = {}) => {
|
|
152
|
-
rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}) });
|
|
151
|
+
const reject = (reason, { indeterminate = false, kind = null } = {}) => {
|
|
152
|
+
rejected.push({ tag, reason, ...(indeterminate ? { indeterminate: true } : {}), ...(kind ? { kind } : {}) });
|
|
153
153
|
return null;
|
|
154
154
|
};
|
|
155
155
|
let view;
|
|
@@ -250,6 +250,18 @@ function judgeRelease({ run, repo, tag, digest, profile, rejected, requireCovera
|
|
|
250
250
|
} catch (error) {
|
|
251
251
|
return reject(`unverified: coverage sidecar does not bind this generation (${error.message})`);
|
|
252
252
|
}
|
|
253
|
+
// A CODE release ships only an all-CURRENT corpus. Its install verification
|
|
254
|
+
// (scripts/public-verification-aggregate.mjs) requires coverage.eligibleCurrent === eligibleTotal on
|
|
255
|
+
// every lane, so a release assembled from a degraded generation could never become install-verified
|
|
256
|
+
// and the nightly would stand down on it. The nightly seed may be degraded: the next night re-plans
|
|
257
|
+
// the carried stores. Decided 2026-10-17 with the D10 removal.
|
|
258
|
+
const degradedCount = coverage.degraded.carried.length + coverage.degraded.missing.length;
|
|
259
|
+
if (requireCoverage && degradedCount > 0) {
|
|
260
|
+
return reject(`incompatible for a code release: degraded generation (${coverage.degraded.carried.length} carried: `
|
|
261
|
+
+ `${coverage.degraded.carried.join(', ') || 'none'}; ${coverage.degraded.missing.length} missing: `
|
|
262
|
+
+ `${coverage.degraded.missing.join(', ') || 'none'}); install verification requires every eligible store CURRENT`,
|
|
263
|
+
{ kind: 'degraded' });
|
|
264
|
+
}
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
try {
|
|
@@ -303,7 +315,7 @@ export async function resolveNextCorpusSeed({
|
|
|
303
315
|
const rejected = [];
|
|
304
316
|
let listed = [];
|
|
305
317
|
try {
|
|
306
|
-
listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,createdAt']) || [];
|
|
318
|
+
listed = ghJson(run, ['release', 'list', '--repo', repo, '--limit', String(limit), '--json', 'tagName,isDraft,isPrerelease,createdAt']) || [];
|
|
307
319
|
} catch (error) {
|
|
308
320
|
rejected.push({ tag: null, reason: `release list failed (${error.message})`, indeterminate: true });
|
|
309
321
|
}
|
|
@@ -316,21 +328,38 @@ export async function resolveNextCorpusSeed({
|
|
|
316
328
|
const candidates = [];
|
|
317
329
|
for (const row of corpusShaped) {
|
|
318
330
|
if (row.isDraft) { rejected.push({ tag: row.tagName, reason: 'unverified: release is still a draft' }); continue; }
|
|
331
|
+
// A corpus generation is STAGED as a prerelease and PROMOTED (prerelease flag cleared) only after the
|
|
332
|
+
// customer canary applied it (protected-release.yml corpus-canary -> corpus-promote). A generation
|
|
333
|
+
// still a prerelease was refused by that canary, superseded, or is mid-flight: no customer received
|
|
334
|
+
// it, so no night and no code release may build on it (reviewer finding, 2026-10-17). The committed
|
|
335
|
+
// bootstrap is a code-release tag, never corpus-shaped, so this never touches it.
|
|
336
|
+
if (row.isPrerelease) { rejected.push({ tag: row.tagName, reason: 'unpromoted: still a prerelease (the customer canary never promoted it); not a seed' }); continue; }
|
|
319
337
|
const createdAt = Date.parse(row.createdAt);
|
|
320
338
|
if (!Number.isFinite(createdAt)) { rejected.push({ tag: row.tagName, reason: 'unverified: release has no readable publication time' }); continue; }
|
|
321
339
|
candidates.push({ tag: row.tagName, digest: CORPUS_TAG.exec(row.tagName)[1], createdAt });
|
|
322
340
|
}
|
|
323
341
|
candidates.sort((left, right) => right.createdAt - left.createdAt);
|
|
324
342
|
|
|
325
|
-
const judged = candidates.slice(0, searchBound);
|
|
326
|
-
for (const skipped of candidates.slice(searchBound)) {
|
|
327
|
-
rejected.push({ tag: skipped.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
|
|
328
|
-
}
|
|
329
343
|
const expects = { model: compatibility.model, dimensions: compatibility.dimensions, fixtureSha256: compatibility.fixtureSha256 };
|
|
330
|
-
|
|
344
|
+
// The bound counts generations JUDGED incompatible or unverifiable (ADR-0091 D4: one bad format change can
|
|
345
|
+
// cost at most SEARCH_BOUND downloads). In code-release mode a DEGRADED promoted generation is skipped
|
|
346
|
+
// without consuming it: a carried store is a transient state the next night re-plans, and if each such
|
|
347
|
+
// night used up the bound, SEARCH_BOUND degraded nights in a row would walk every code release back to the
|
|
348
|
+
// committed bootstrap (a pre-ADR-0091 corpus) with nothing paging (reviewer finding, 2026-10-17). The
|
|
349
|
+
// release listing (`--limit`) remains the absolute cap on the walk.
|
|
350
|
+
let judged = 0;
|
|
351
|
+
let skippedDegraded = 0;
|
|
352
|
+
for (const candidate of candidates) {
|
|
353
|
+
if (judged >= searchBound) {
|
|
354
|
+
rejected.push({ tag: candidate.tag, reason: `not judged: older than the ${searchBound} newest generations (ADR-0091 D4 search bound)` });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
331
357
|
const resolved = judgeRelease({ run, repo, tag: candidate.tag, digest: candidate.digest, profile: compatibility, rejected,
|
|
332
358
|
requireCoverage });
|
|
333
|
-
if (resolved) return { seed: resolved, rejected, judged: judged
|
|
359
|
+
if (resolved) return { seed: resolved, rejected, judged: judged + 1, skippedDegraded, expects };
|
|
360
|
+
if (requireCoverage && rejected[rejected.length - 1]?.tag === candidate.tag && rejected[rejected.length - 1]?.kind === 'degraded') {
|
|
361
|
+
skippedDegraded += 1;
|
|
362
|
+
} else judged += 1;
|
|
334
363
|
}
|
|
335
364
|
|
|
336
365
|
return {
|
|
@@ -344,7 +373,8 @@ export async function resolveNextCorpusSeed({
|
|
|
344
373
|
brainVersion: null,
|
|
345
374
|
},
|
|
346
375
|
rejected,
|
|
347
|
-
judged
|
|
376
|
+
judged,
|
|
377
|
+
skippedDegraded,
|
|
348
378
|
expects,
|
|
349
379
|
};
|
|
350
380
|
}
|
|
@@ -18,7 +18,7 @@ import { rebuildCorpusAggregates } from './corpus-aggregates.mjs';
|
|
|
18
18
|
import { assertCapabilityOnlyStore, isCapabilityOnly, CAPABILITY_RETIRED_SUFFIXES } from '../kb/capability-only.mjs';
|
|
19
19
|
import { eligibleRepositoryStanding, fileIdentity, validateCoverageLedger } from '../plugin/scripts/coverage-integrity.mjs';
|
|
20
20
|
import {
|
|
21
|
-
CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound,
|
|
21
|
+
CORPUS_QA_FAILED_EXIT, FAILURE_CLASS, StoreWorkerError, degradedBound, describeDegraded, failureReason, isRetryable,
|
|
22
22
|
} from './corpus-store-failure.mjs';
|
|
23
23
|
import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
|
|
24
24
|
import { storeRoot } from '../kb/store-root.mjs';
|
|
@@ -132,10 +132,6 @@ function filesNamed(root, wanted) {
|
|
|
132
132
|
// corpus-seed.yml can re-run seed extraction ONCE from the committed bootstrap in the same job.
|
|
133
133
|
export const SEED_LEDGER_SCHEMA_VERSION = 2;
|
|
134
134
|
export const SEED_LEDGER_INCOMPATIBLE_EXIT = 3;
|
|
135
|
-
// ADR-0091 D5 + D10: the generation was built and SEALED with carried/missing stores, but degraded
|
|
136
|
-
// publication is not yet allowed (no soaked tolerant-validator transition). It must not be published;
|
|
137
|
-
// the next night re-plans the carried stores automatically because their sourceCommit still differs.
|
|
138
|
-
export const DEGRADED_UNPUBLISHED_EXIT = 4;
|
|
139
135
|
|
|
140
136
|
export class SeedLedgerIncompatibleError extends Error {
|
|
141
137
|
constructor(reason) {
|
|
@@ -1256,17 +1252,13 @@ export async function main(argv = process.argv.slice(2), {
|
|
|
1256
1252
|
return 0;
|
|
1257
1253
|
}
|
|
1258
1254
|
const { plan } = summarizeReconciliation(reconciliation);
|
|
1255
|
+
// ADR-0091 D5: a generation inside the degraded bound (assessCandidateCoverage already refused one
|
|
1256
|
+
// above it) is sealed and goes on to be published like any other; the night's log and step summary
|
|
1257
|
+
// say which stores were carried or are missing, and each row says so in the sealed coverage.
|
|
1259
1258
|
const degraded = candidate.degraded || { carried: [], missing: [] };
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
degraded: { ...degraded, publishable: publication.allowed, reason: publication.reason } }, null, 2)}\n`);
|
|
1264
|
-
if (!publication.allowed) {
|
|
1265
|
-
stderr.write(`::warning title=Degraded corpus generation sealed, not published::${degraded.carried.length} carried `
|
|
1266
|
-
+ `(${degraded.carried.map((row) => row.store).join(', ') || 'none'}), ${degraded.missing.length} missing `
|
|
1267
|
-
+ `(${degraded.missing.map((row) => row.store).join(', ') || 'none'}); ${publication.reason}\n`);
|
|
1268
|
-
return DEGRADED_UNPUBLISHED_EXIT;
|
|
1269
|
-
}
|
|
1259
|
+
const summary = describeDegraded(degraded);
|
|
1260
|
+
stdout.write(`${JSON.stringify({ ok: true, seedTag, seedSha256, plan, reconciliation, ...candidate, degraded }, null, 2)}\n`);
|
|
1261
|
+
if (summary) stderr.write(`::warning title=Degraded corpus generation::${summary}; the carried stores keep their last verified bytes and are re-planned next night\n`);
|
|
1270
1262
|
return 0;
|
|
1271
1263
|
}
|
|
1272
1264
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ADR-0091 D5 — per-store failure isolation for corpus reconciliation.
|
|
2
2
|
//
|
|
3
|
-
// One store failing to refresh must not abort the whole generation. This module owns the
|
|
3
|
+
// One store failing to refresh must not abort the whole generation. This module owns the two
|
|
4
4
|
// pure decisions that make that safe, so the reconciler, the rehearsal and the tests share them:
|
|
5
5
|
//
|
|
6
6
|
// 1. WHICH failures are retried. Only transient ones (clone, fetch, runner I/O), exactly once, in a
|
|
@@ -10,11 +10,25 @@
|
|
|
10
10
|
// from log text.
|
|
11
11
|
// 2. HOW MANY stores may be carried or missing before the failure is systemic and the generation
|
|
12
12
|
// must fail loudly: carried + missing <= max(3, 5% of eligible).
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
//
|
|
14
|
+
// A generation inside that bound PUBLISHES like any other, each carried or missing store named in its
|
|
15
|
+
// sealed coverage (CORPUS-COVERAGE.json: `carry` / `failure` records). Whether installed clients accept
|
|
16
|
+
// it is proven every night by the customer canary (protected-release.yml `customer-canary`: a clean
|
|
17
|
+
// install, a private-overlay install and the previous runtime each apply the staged candidate through
|
|
18
|
+
// the real updater before anything is promoted; scripts/corpus-canary.mjs storeFreshness names each
|
|
19
|
+
// carried store), not by a hand-set constant. The former ADR-0091 D10 gate -- a committed
|
|
20
|
+
// `VALIDATOR_TRANSITION` plus a 14-day soak -- was removed on 2026-10-17: the tolerant validator
|
|
21
|
+
// (`eligibleRepositoryStanding`) has shipped in every code release since v4.3.36 (2026-09-29) and every
|
|
22
|
+
// corpus generation since 2026-10-01, the soak it demanded ended on 2026-10-13, and the constant was
|
|
23
|
+
// never set. Measured on the one night it decided (run 39069376585, 2026-10-17, runtime 4.7.10): the
|
|
24
|
+
// external source cognitum-one/ruOS had been deleted upstream; 4.7.10's `ifGone` entry carried it at
|
|
25
|
+
// b1762a71 while the live corpus served 576dc8c8, so the store was re-planned, the clone of the gone
|
|
26
|
+
// repository failed twice, the store was carried, the 201-store generation was built and sealed over
|
|
27
|
+
// ~1h40m, and this gate threw it away with exit 4 (4.7.11 fixed the `ifGone` commit). The four nights
|
|
28
|
+
// before it (38529261934, 38699673841, 38867855193, 39013743592) died earlier, exit 1, on the HTTP 404
|
|
29
|
+
// itself. A gate that only a code release can open, that nobody opens, is not a safety property.
|
|
30
|
+
// A CODE release still ships only an all-CURRENT corpus: scripts/corpus-next-seed.mjs --require-coverage
|
|
31
|
+
// refuses a degraded seed, because install verification requires eligibleCurrent === eligibleTotal.
|
|
18
32
|
|
|
19
33
|
/** kb/forge-refresh.mjs exits with this status when corpus-qa rejected its candidate. */
|
|
20
34
|
export const CORPUS_QA_FAILED_EXIT = 3;
|
|
@@ -57,26 +71,11 @@ export function degradedBound(eligibleCount) {
|
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* D10 then records { version, latestSince } here. Null means no transition: never publish degraded.
|
|
74
|
+
* One line a run log or step summary can print for a generation's carried/missing stores; '' when none.
|
|
75
|
+
* Rows are the reconciler's `{ store, ... }` records or the coverage sidecar's bare store names.
|
|
63
76
|
*/
|
|
64
|
-
export
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!transition) {
|
|
69
|
-
return { allowed: false,
|
|
70
|
-
reason: 'no tolerant-validator transition release is recorded (ADR-0091 D10); installed clients would reject a non-CURRENT row' };
|
|
71
|
-
}
|
|
72
|
-
const since = Date.parse(transition.latestSince);
|
|
73
|
-
if (typeof transition.version !== 'string' || !transition.version || !Number.isFinite(since)) {
|
|
74
|
-
return { allowed: false, reason: 'the recorded validator transition is malformed' };
|
|
75
|
-
}
|
|
76
|
-
const soakEnds = since + TRANSITION_SOAK_DAYS * 24 * 60 * 60 * 1000;
|
|
77
|
-
if (now.getTime() < soakEnds) {
|
|
78
|
-
return { allowed: false,
|
|
79
|
-
reason: `validator transition ${transition.version} soaks until ${new Date(soakEnds).toISOString()} (ADR-0091 D10)` };
|
|
80
|
-
}
|
|
81
|
-
return { allowed: true, reason: `validator transition ${transition.version} has soaked ${TRANSITION_SOAK_DAYS} days` };
|
|
77
|
+
export function describeDegraded({ carried = [], missing = [] } = {}) {
|
|
78
|
+
if (!carried.length && !missing.length) return '';
|
|
79
|
+
const names = (rows) => rows.map((row) => (typeof row === 'string' ? row : row.store)).join(', ') || 'none';
|
|
80
|
+
return `${carried.length} carried (${names(carried)}), ${missing.length} missing (${names(missing)})`;
|
|
82
81
|
}
|
|
@@ -188,7 +188,21 @@ export function judgeCorpusHealth(input, now) {
|
|
|
188
188
|
// (c) the newest code release must reach a verified aggregate within 24h of publish.
|
|
189
189
|
const code = input?.codeRelease;
|
|
190
190
|
if (!code) add(RED, 'code-aggregate', 'no published vX.Y.Z code release was found');
|
|
191
|
-
else if (code.aggregate?.state === 'verified')
|
|
191
|
+
else if (code.aggregate?.state === 'verified') {
|
|
192
|
+
add(GREEN, 'code-aggregate', `${code.tag} carries a verified PASS aggregate`);
|
|
193
|
+
// (c2) THE CORPUS FRESH INSTALLS RECEIVE. A code release ships a corpus too, and its signed aggregate
|
|
194
|
+
// records how many eligible stores the three install lanes found CURRENT. The aggregate VERIFIES only when
|
|
195
|
+
// eligibleCurrent === eligibleTotal (scripts/public-verification-aggregate.mjs), so a verified code release
|
|
196
|
+
// never ships a carried or missing store; this line says so with the measured counts rather than leaving
|
|
197
|
+
// it implied. Counts that are absent (the verifier does not require their presence) are reported, not
|
|
198
|
+
// read as "all current".
|
|
199
|
+
const shipped = code.aggregate.coverage;
|
|
200
|
+
if (!shipped || !Number.isSafeInteger(shipped.eligibleTotal) || !Number.isSafeInteger(shipped.eligibleCurrent)) {
|
|
201
|
+
add(WARNING, 'code-corpus', `${code.tag}: the aggregate records no eligible/CURRENT store counts; what fresh installs receive is unmeasured`);
|
|
202
|
+
} else {
|
|
203
|
+
add(GREEN, 'code-corpus', `${code.tag} ships ${shipped.eligibleCurrent}/${shipped.eligibleTotal} eligible stores CURRENT`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
192
206
|
else if (code.aggregate?.state === 'invalid') add(RED, 'code-aggregate', `${code.tag} carries an aggregate that does not verify `
|
|
193
207
|
+ `under this checkout's verifier (the one approved-runtime --resolve runs, so the nightly will refuse it too): ${code.aggregate.reason}`);
|
|
194
208
|
else {
|
|
@@ -210,7 +224,10 @@ export function judgeCorpusHealth(input, now) {
|
|
|
210
224
|
.map((row) => `${row.store} (${row.lowerBound ? '≥' : ''}${hours(age(row.since))})`).join(', ')}`);
|
|
211
225
|
} else add(GREEN, 'deferral', `${(deferral.stores || []).length} deferred store(s), none older than 7 days`);
|
|
212
226
|
const degraded = [...(deferral.degraded?.carried || []), ...(deferral.degraded?.missing || [])];
|
|
213
|
-
if (degraded.length)
|
|
227
|
+
if (degraded.length) {
|
|
228
|
+
add(WARNING, 'degraded', `${deferral.newestTag} is degraded: ${deferral.degraded.carried.length} carried `
|
|
229
|
+
+ `(${deferral.degraded.carried.join(', ') || 'none'}), ${deferral.degraded.missing.length} missing (${deferral.degraded.missing.join(', ') || 'none'})`);
|
|
230
|
+
}
|
|
214
231
|
}
|
|
215
232
|
|
|
216
233
|
// (e) the generation customers receive. Its own generation stamp when it carries one (the corpus
|
|
@@ -267,7 +284,12 @@ async function aggregateState({ gh, repo, tag, root, scratch }) {
|
|
|
267
284
|
const { verifyPublicVerificationAggregate } = await import('./public-verification-aggregate.mjs');
|
|
268
285
|
verifyPublicVerificationAggregate(aggregate, crypto.createPublicKey(fs.readFileSync(path.join(root, SIGNING_PUBLIC_KEY_FILE), 'utf8')));
|
|
269
286
|
if (aggregate.identity?.tag !== tag) throw new Error(`aggregate describes ${aggregate.identity?.tag}, not ${tag}`);
|
|
270
|
-
|
|
287
|
+
// The corpus a CODE release ships, as its three install lanes measured it: the signed aggregate carries
|
|
288
|
+
// the eligible/CURRENT counts and the coverage generation of the installed COVERAGE.json. The code
|
|
289
|
+
// release publishes no coverage sidecar of its own (verified 2026-10-17: v4.7.10 assets), so this is
|
|
290
|
+
// the one small, signed place that says what fresh installs receive.
|
|
291
|
+
return { state: 'verified', coverage: { eligibleTotal: aggregate.coverage?.eligibleTotal ?? null,
|
|
292
|
+
eligibleCurrent: aggregate.coverage?.eligibleCurrent ?? null, coverageGeneration: aggregate.identity?.coverageGeneration ?? null } };
|
|
271
293
|
} catch (error) {
|
|
272
294
|
return { state: 'invalid', reason: error.message.slice(0, 200) };
|
|
273
295
|
}
|
|
@@ -917,17 +917,11 @@ async function runGeneration({ index, api, checkoutRoot, workRoot, seed, bounds,
|
|
|
917
917
|
});
|
|
918
918
|
log(`[gen ${index}] extracted-byte verification passed with staging revoked (${generation.extractedByteVerification.files} files)`);
|
|
919
919
|
|
|
920
|
-
// --- ADR-0091 D5
|
|
920
|
+
// --- ADR-0091 D5: a degraded generation inside the bound is published like any other, named -----
|
|
921
921
|
const corpusTag = `corpus-sha256-${archiveSha256}`;
|
|
922
922
|
if (generation.degraded.carried.length || generation.degraded.missing.length) {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
generation.publication = { status: 'WITHHELD-DEGRADED', corpusTag, reason: decision.reason,
|
|
926
|
-
carried: generation.degraded.carried.map((entry) => entry.store), missing: generation.degraded.missing.map((entry) => entry.store) };
|
|
927
|
-
generation.finishedAt = new Date().toISOString();
|
|
928
|
-
log(`[gen ${index}] degraded generation sealed and WITHHELD from publication: ${decision.reason}`);
|
|
929
|
-
return { generation, withheld: true, nextSeed: null, published: null };
|
|
930
|
-
}
|
|
923
|
+
log(`[gen ${index}] degraded generation (${generation.degraded.carried.length} carried, `
|
|
924
|
+
+ `${generation.degraded.missing.length} missing) is published with its stores named in the sealed coverage`);
|
|
931
925
|
}
|
|
932
926
|
|
|
933
927
|
// --- rehearse the real product-consumption path, publication RECORDED not executed -----------
|
|
@@ -1371,7 +1365,6 @@ export async function rehearseCorpusPipeline({
|
|
|
1371
1365
|
prepareCorpusCandidate: reconcileMod.prepareCorpusCandidate,
|
|
1372
1366
|
executeReconciliation: reconcileMod.executeReconciliation,
|
|
1373
1367
|
defaultRunAsync: reconcileMod.defaultRunAsync,
|
|
1374
|
-
degradedPublication: failureMod.degradedPublication,
|
|
1375
1368
|
observeSourceUniverse: coverageMod.observeSourceUniverse,
|
|
1376
1369
|
canonicalSourceObservation: coverageMod.canonicalSourceObservation,
|
|
1377
1370
|
buildCoverage: coverageMod.buildCoverage,
|
|
@@ -1479,14 +1472,6 @@ export async function rehearseCorpusPipeline({
|
|
|
1479
1472
|
phase(`generation-${index}-approved-runtime`, 'PASS', { ...runtimeCheck,
|
|
1480
1473
|
reason: `verifyApprovedRuntime PASS: ${verdict.checked} runtime files equal the checkout's, none unpinned, seed ${seed.tag} did not leak an executable` });
|
|
1481
1474
|
|
|
1482
|
-
if (result.withheld) {
|
|
1483
|
-
phase(`generation-${index}-publication`, 'PASS', { status: 'WITHHELD-DEGRADED', ...result.generation.publication,
|
|
1484
|
-
reason: `degraded generation sealed but not published (ADR-0091 D5/D10): ${result.generation.publication.reason}` });
|
|
1485
|
-
if (index < generations) {
|
|
1486
|
-
phase('remaining-generations', 'SKIP', { reason: `generation ${index} was withheld, so there is no published generation ${index} to seed from` });
|
|
1487
|
-
}
|
|
1488
|
-
break;
|
|
1489
|
-
}
|
|
1490
1475
|
const consumeNow = seedSelection && codeReleaseConsumption && !codeRelease;
|
|
1491
1476
|
if (seedSelection && (index < generations || codeRelease)) {
|
|
1492
1477
|
lastPublished = publishLocally({ selectionMod, registryDir, published: result.published, workRoot, index });
|
package/scripts/release.mjs
CHANGED
|
@@ -69,7 +69,7 @@ import {
|
|
|
69
69
|
CORPUS_GENERATION_FIELD, CORPUS_TAG_PATTERN, evaluateCanaryVerdict, evaluateCorpusPromotion, parseCorpusGeneration,
|
|
70
70
|
} from './corpus-promotion.mjs';
|
|
71
71
|
import { bindCoverageToReceipt, writeCoverageAssets } from './corpus-coverage-sidecar.mjs';
|
|
72
|
-
import {
|
|
72
|
+
import { describeDegraded } from './corpus-store-failure.mjs';
|
|
73
73
|
import { assertNoNewerCorpusGeneration } from './code-release-corpus.mjs';
|
|
74
74
|
|
|
75
75
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
@@ -404,12 +404,12 @@ export async function runProtectedCorpusSeed({
|
|
|
404
404
|
corpusFailure(`corpus receipt does not verify against the sealed archive (${error.message})`);
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
-
// ADR-0091 D6.2
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
407
|
+
// ADR-0091 D6.2. The archive carries no coverage and the schema-3 receipt binds none, so this is the
|
|
408
|
+
// one place the publisher can SEE whether the generation is degraded. The coverage must be the
|
|
409
|
+
// coverage of THIS archive (bound store by store to the receipt) and is published beside the archive
|
|
410
|
+
// as CORPUS-COVERAGE.json + coverage-receipt.json (no receipt schema bump). A generation inside the
|
|
411
|
+
// degraded bound is staged like any other: the customer canary, not a constant, decides whether
|
|
412
|
+
// installed clients accept it before it is promoted. Local, before any network.
|
|
413
413
|
let coverageAssets;
|
|
414
414
|
try {
|
|
415
415
|
coverageAssets = writeCoverageAssets({
|
|
@@ -419,13 +419,8 @@ export async function runProtectedCorpusSeed({
|
|
|
419
419
|
const degraded = bindCoverageToReceipt({
|
|
420
420
|
coverage: JSON.parse(fs.readFileSync(coverageAssets.coverageFile, 'utf8')), receipt,
|
|
421
421
|
});
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (!decision.allowed) {
|
|
425
|
-
corpusFailure(`degraded generation (${degraded.carried.length} carried, ${degraded.missing.length} missing) `
|
|
426
|
-
+ `must not be published: ${decision.reason}`);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
422
|
+
const summary = describeDegraded(degraded);
|
|
423
|
+
if (summary) console.error(`[corpus-seed] degraded generation staged: ${summary} (each named in CORPUS-COVERAGE.json)`);
|
|
429
424
|
} catch (error) {
|
|
430
425
|
if (String(error.message).startsWith('[corpus-seed]')) throw error;
|
|
431
426
|
corpusFailure(`the generation's sealed coverage does not bind this archive (${error.message})`);
|
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { readCheckpoint, checkpointStaleness } from './loop-checkpoint.mjs';
|
|
18
18
|
import { PRODUCTION_ENVIRONMENT, productionEnvironmentVerdict } from './release-environment-policy.mjs';
|
|
19
19
|
import { approvalHits, LOCAL_INSTRUCTION_DRIFT } from './human-approval-phrases.mjs';
|
|
20
|
+
import { deriveWrapperHistory, describeDrift, historyDrift, readWrapperHistory } from './codex-hook-wrapper-history.mjs';
|
|
20
21
|
|
|
21
22
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
23
|
const HOME = os.homedir();
|
|
@@ -192,6 +193,19 @@ const checks = [
|
|
|
192
193
|
return none([...extra, ...drift]);
|
|
193
194
|
} },
|
|
194
195
|
|
|
196
|
+
// The installer proves a pre-existing Codex hook bridge is the Brain's own by its bytes: the new source, a
|
|
197
|
+
// retained Spine generation, or (2026-10-17) any wrapper an earlier release tag shipped, from the committed
|
|
198
|
+
// data/codex-hook-wrapper-history.json. That file is derived from git; a release that ships NEW wrapper
|
|
199
|
+
// bytes without regenerating it would make the next upgrade of those bytes a conflict again.
|
|
200
|
+
{ id: 'D4', area: 'corpus', scope: 'repo', title: 'The committed Codex hook wrapper history carries exactly the wrappers the release tags shipped',
|
|
201
|
+
run: () => {
|
|
202
|
+
const derived = deriveWrapperHistory(ROOT);
|
|
203
|
+
if (!derived.releaseTags) return { ok: true, detail: 'no vX.Y.Z tags visible to git here; nothing to compare' };
|
|
204
|
+
const drift = historyDrift(readWrapperHistory(ROOT), derived);
|
|
205
|
+
const ok = !drift.missing.length && !drift.extra.length;
|
|
206
|
+
return { ok, detail: ok ? `${derived.wrappers.length} wrapper(s) across ${derived.releaseTags} tag(s)`
|
|
207
|
+
: `${describeDrift(drift)}; run node scripts/codex-hook-wrapper-history.mjs --write` };
|
|
208
|
+
} },
|
|
195
209
|
{ id: 'D2', area: 'corpus', scope: 'machine', title: 'Installed knowledge matches the installed plugin version',
|
|
196
210
|
run: () => {
|
|
197
211
|
const tag = String(json(readAbs(path.join(HOME, '.cache/ruvnet-brain/kb/SOURCE.json'))).releaseTag || '').replace(/^v/, '');
|