ruvnet-brain 3.9.134-dev → 4.0.1
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/.claude-plugin/marketplace.json +13 -0
- package/README.md +2 -2
- package/bin/install.mjs +284 -33
- package/kb/zip-extract.mjs +53 -14
- package/package.json +7 -1
- package/plugin/.claude-plugin/marketplace.json +13 -0
- package/plugin/.claude-plugin/plugin.json +23 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +32 -0
- package/plugin/commands/rvbc.md +78 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +203 -0
- package/plugin/mcp/server.mjs +35 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +168 -0
- package/plugin/scripts/finalize-token-meter.mjs +25 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +507 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +154 -0
- package/plugin/scripts/learn-flush.mjs +138 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/session-start.sh +868 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +20 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +20 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// update-apply.mjs — THE single writer of the Stable Spine (ADR-023, docs/INTELLIGENT-UPDATING.md,
|
|
3
|
+
// DDD-0003 INV-2). Every path that changes what code runs — seed/migration, unattended session
|
|
4
|
+
// update, manual apply, rollback, dev mode, GC — goes through here, under one lock, with a
|
|
5
|
+
// persisted transaction record. Consumers (hook-shim.mjs, mcp/server.mjs) only ever READ.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/update-apply.mjs --seed # migrate: seed the spine from the running plugin install
|
|
8
|
+
// node scripts/update-apply.mjs --auto # unattended: newest CC-staged plugin version → gate → flip
|
|
9
|
+
// node scripts/update-apply.mjs --from-dir <path> # apply a specific plugin payload dir
|
|
10
|
+
// node scripts/update-apply.mjs --rollback # flip back to `previous` (instant)
|
|
11
|
+
// node scripts/update-apply.mjs --dev [pluginDir] # dev mode: point codeRoot at a git checkout's plugin/
|
|
12
|
+
// node scripts/update-apply.mjs --dev-off
|
|
13
|
+
// node scripts/update-apply.mjs --gc # collect unreferenced generations (leases respected)
|
|
14
|
+
// node scripts/update-apply.mjs --doctor # report spine state, honestly
|
|
15
|
+
//
|
|
16
|
+
// Layout under ~/.cache/ruvnet-brain/ (all writes atomic temp+rename; NO symlinks — works
|
|
17
|
+
// unprivileged on macOS/Linux/Windows, red-team finding 6):
|
|
18
|
+
// active.json { generation, version, codeRoot, previous:{...}, flippedAt }
|
|
19
|
+
// versions/<v>/ immutable plugin-payload copies (scripts/, hooks/, mcp/, …)
|
|
20
|
+
// .update.lock/ mkdir-atomic lock dir (owner.json inside; stale-pid reclaimed) [finding 5]
|
|
21
|
+
// update-txn.json transaction record; crash recovery is deterministic [finding 21]
|
|
22
|
+
// update-receipts.jsonl append-only ledger of every check/gate/flip/rollback
|
|
23
|
+
// leases/<gen>.json consumers lease the generation they serve; GC never collects a fresh lease [finding 23]
|
|
24
|
+
// dev.json opt-in dev-mode marker; --auto NEVER flips away from dev [finding 24]
|
|
25
|
+
//
|
|
26
|
+
// TRUST BOUNDARY for unattended applies (findings 10/11, honest v1 scope): --auto applies ONLY
|
|
27
|
+
// version dirs that Claude Code's own marketplace update already staged locally (integrity: GitHub
|
|
28
|
+
// over TLS + CC's git clone of the marketplace). Applying a downloaded BUNDLE unattended requires
|
|
29
|
+
// the Ed25519-signed path (bin/install.mjs owns the key) and is wired there, not here. --from-dir
|
|
30
|
+
// is interactive-only by definition (a human typed the path).
|
|
31
|
+
//
|
|
32
|
+
// This engine NEVER touches kb/ (DDD-0003 INV-6): KB data has its own lifecycle and fence.
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import crypto from 'node:crypto';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
42
|
+
const ACTIVE = path.join(BRAIN_HOME, 'active.json');
|
|
43
|
+
const VERSIONS = path.join(BRAIN_HOME, 'versions');
|
|
44
|
+
const LOCK = path.join(BRAIN_HOME, '.update.lock');
|
|
45
|
+
const TXN = path.join(BRAIN_HOME, 'update-txn.json');
|
|
46
|
+
const RECEIPTS = path.join(BRAIN_HOME, 'update-receipts.jsonl');
|
|
47
|
+
const LEASES = path.join(BRAIN_HOME, 'leases');
|
|
48
|
+
const DEV = path.join(BRAIN_HOME, 'dev.json');
|
|
49
|
+
const SEEDED = path.join(BRAIN_HOME, '.spine-seeded');
|
|
50
|
+
const PLUGIN_CACHES = [
|
|
51
|
+
path.join(os.homedir(), '.claude', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
52
|
+
path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
|
|
53
|
+
];
|
|
54
|
+
const LEASE_FRESH_MS = 6 * 3600_000; // a lease older than 6h is stale (its process is long gone)
|
|
55
|
+
|
|
56
|
+
const argv = process.argv.slice(2);
|
|
57
|
+
const has = (f) => argv.includes(f);
|
|
58
|
+
const val = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
|
|
59
|
+
|
|
60
|
+
// ── atomic primitives ────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
function writeAtomic(file, content) {
|
|
62
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
63
|
+
fs.writeFileSync(tmp, content);
|
|
64
|
+
fs.renameSync(tmp, file); // atomic on macOS/Linux/Windows
|
|
65
|
+
}
|
|
66
|
+
function readJSON(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
|
|
67
|
+
function receipt(event, data) {
|
|
68
|
+
try { fs.appendFileSync(RECEIPTS, JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + '\n'); } catch { /* ledger must not break the engine */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── the lock (finding 5): atomic mkdir; stale-pid reclaim (issue-fix.mjs house pattern) ──────────
|
|
72
|
+
function acquireLock(target) {
|
|
73
|
+
fs.mkdirSync(BRAIN_HOME, { recursive: true });
|
|
74
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
fs.mkdirSync(LOCK); // atomic: exists → EEXIST
|
|
77
|
+
writeAtomic(path.join(LOCK, 'owner.json'), JSON.stringify({ pid: process.pid, host: os.hostname(), target, startedAt: new Date().toISOString() }));
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
const owner = readJSON(path.join(LOCK, 'owner.json'));
|
|
81
|
+
try { if (owner && owner.pid) { process.kill(owner.pid, 0); return false; } } catch { /* stale */ }
|
|
82
|
+
try { fs.rmSync(LOCK, { recursive: true, force: true }); } catch { /* race — retry once */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function releaseLock() { try { fs.rmSync(LOCK, { recursive: true, force: true }); } catch { /* gone */ } }
|
|
88
|
+
|
|
89
|
+
// ── crash recovery (finding 21): resolve any incomplete transaction BEFORE new work ─────────────
|
|
90
|
+
function recoverTxn() {
|
|
91
|
+
const txn = readJSON(TXN);
|
|
92
|
+
if (!txn || txn.state === 'active' || txn.state === 'rolled-back') return;
|
|
93
|
+
// candidate-ready but never flipped: the candidate dir is valid & gated — leave it (a future run
|
|
94
|
+
// may flip it); staging remnants are junk. Either way the active pointer was never touched, so
|
|
95
|
+
// the world is the OLD world. Clean staging, close the txn honestly.
|
|
96
|
+
for (const d of fs.existsSync(VERSIONS) ? fs.readdirSync(VERSIONS) : []) {
|
|
97
|
+
if (d.includes('.staging-')) { try { fs.rmSync(path.join(VERSIONS, d), { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
98
|
+
}
|
|
99
|
+
writeAtomic(TXN, JSON.stringify({ ...txn, state: 'recovered-old-world', recoveredAt: new Date().toISOString() }));
|
|
100
|
+
receipt('TxnRecovered', { from: txn.state, target: txn.to });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── gates (finding 20): interpreter-true syntax + structural checks on the CANDIDATE ────────────
|
|
104
|
+
function gateCandidate(dir) {
|
|
105
|
+
const problems = [];
|
|
106
|
+
const scripts = path.join(dir, 'scripts');
|
|
107
|
+
if (!fs.existsSync(scripts)) problems.push('no scripts/ dir — not a plugin payload');
|
|
108
|
+
const hooksJson = path.join(dir, 'hooks', 'hooks.json');
|
|
109
|
+
if (fs.existsSync(hooksJson)) {
|
|
110
|
+
try { JSON.parse(fs.readFileSync(hooksJson, 'utf8')); } catch (e) { problems.push(`hooks.json unparseable: ${e.message}`); }
|
|
111
|
+
}
|
|
112
|
+
// Interpreter-true, platform-honest: bash-check .sh only where /bin/bash exists (the hooks are
|
|
113
|
+
// "_platform":"posix"-declared — on Windows they never execute, so a bash syntax check there
|
|
114
|
+
// would be checking with an interpreter the machine doesn't have). node --check gates everywhere.
|
|
115
|
+
const haveBash = fs.existsSync('/bin/bash');
|
|
116
|
+
for (const f of fs.existsSync(scripts) ? fs.readdirSync(scripts) : []) {
|
|
117
|
+
const p = path.join(scripts, f);
|
|
118
|
+
if (f.endsWith('.sh')) {
|
|
119
|
+
if (!haveBash) continue;
|
|
120
|
+
const r = spawnSync('/bin/bash', ['-n', p], { encoding: 'utf8' });
|
|
121
|
+
if (r.status !== 0) problems.push(`bash -n ${f}: ${(r.stderr || '').trim()}`);
|
|
122
|
+
} else if (f.endsWith('.mjs') || f.endsWith('.js')) {
|
|
123
|
+
const r = spawnSync(process.execPath, ['--check', p], { encoding: 'utf8' });
|
|
124
|
+
if (r.status !== 0) problems.push(`node --check ${f}: ${(r.stderr || '').trim()}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return problems;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── copy a payload into an immutable version dir (staged; never into a live dir) ────────────────
|
|
131
|
+
function safeVersion(version) {
|
|
132
|
+
if (typeof version !== 'string'
|
|
133
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/.test(version)
|
|
134
|
+
|| version.includes('..')) {
|
|
135
|
+
throw new Error(`unsafe payload version: ${JSON.stringify(version)}`);
|
|
136
|
+
}
|
|
137
|
+
return version;
|
|
138
|
+
}
|
|
139
|
+
function versionPath(version, suffix = '') {
|
|
140
|
+
const root = path.resolve(VERSIONS);
|
|
141
|
+
const target = path.resolve(root, `${safeVersion(version)}${suffix}`);
|
|
142
|
+
if (path.dirname(target) !== root) throw new Error(`payload version escapes versions/: ${JSON.stringify(version)}`);
|
|
143
|
+
return target;
|
|
144
|
+
}
|
|
145
|
+
function ensureVersionsRoot() {
|
|
146
|
+
fs.mkdirSync(VERSIONS, { recursive: true });
|
|
147
|
+
if (fs.lstatSync(VERSIONS).isSymbolicLink()) {
|
|
148
|
+
throw new Error('versions/ must be a real directory, not a symbolic link');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function assertNoSymlinks(root) {
|
|
152
|
+
if (fs.lstatSync(root).isSymbolicLink()) {
|
|
153
|
+
throw new Error('payload root is a symbolic link');
|
|
154
|
+
}
|
|
155
|
+
const pending = [root];
|
|
156
|
+
while (pending.length) {
|
|
157
|
+
const current = pending.pop();
|
|
158
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
159
|
+
const candidate = path.join(current, entry.name);
|
|
160
|
+
const stat = fs.lstatSync(candidate);
|
|
161
|
+
if (stat.isSymbolicLink()) {
|
|
162
|
+
throw new Error(`payload contains a symbolic link: ${path.relative(root, candidate)}`);
|
|
163
|
+
}
|
|
164
|
+
if (stat.isDirectory()) pending.push(candidate);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function payloadDigest(root) {
|
|
169
|
+
const hash = crypto.createHash('sha256');
|
|
170
|
+
const visit = (dir, prefix = '') => {
|
|
171
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
172
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
173
|
+
const full = path.join(dir, entry.name);
|
|
174
|
+
hash.update(`${entry.isDirectory() ? 'd' : 'f'}\0${rel}\0`);
|
|
175
|
+
if (entry.isDirectory()) visit(full, rel);
|
|
176
|
+
else hash.update(fs.readFileSync(full));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
visit(root);
|
|
180
|
+
return hash.digest('hex');
|
|
181
|
+
}
|
|
182
|
+
function stagePayload(srcDir, version) {
|
|
183
|
+
ensureVersionsRoot();
|
|
184
|
+
assertNoSymlinks(srcDir);
|
|
185
|
+
const staging = versionPath(version, `.staging-${process.pid}`);
|
|
186
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
187
|
+
try {
|
|
188
|
+
fs.cpSync(srcDir, staging, { recursive: true, dereference: false });
|
|
189
|
+
// Scan the copy too: a source entry swapped after the pre-copy lstat must never survive the
|
|
190
|
+
// time-of-check/time-of-use window into an activated generation.
|
|
191
|
+
assertNoSymlinks(staging);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
return staging;
|
|
197
|
+
}
|
|
198
|
+
function promote(staging, version) {
|
|
199
|
+
const finalDir = versionPath(version);
|
|
200
|
+
fs.rmSync(finalDir, { recursive: true, force: true }); // re-promote of same version is legal (idempotent)
|
|
201
|
+
fs.renameSync(staging, finalDir);
|
|
202
|
+
return finalDir;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── the flip: rewrite active.json (THE update) ──────────────────────────────────────────────────
|
|
206
|
+
// The SHELL set — files Claude Code binds at boot. If none of these changed between generations,
|
|
207
|
+
// the update is fully live with no restart and session-start stays silent; if any did, the ONE
|
|
208
|
+
// honest nag fires (the release classifier computes the same thing publish-side; this is the
|
|
209
|
+
// client-side truth for locally-applied generations). Red-team finding 18's client half.
|
|
210
|
+
const SHELL_PATHS = ['hooks/hooks.json', 'scripts/hook-shim.mjs', 'mcp/server.mjs', '.mcp.json'];
|
|
211
|
+
function shellDiff(prevRootAbs, nextRootAbs) {
|
|
212
|
+
const changed = [];
|
|
213
|
+
for (const rel of SHELL_PATHS) {
|
|
214
|
+
const a = (() => { try { return fs.readFileSync(path.join(prevRootAbs, rel), 'utf8'); } catch { return null; } })();
|
|
215
|
+
const b = (() => { try { return fs.readFileSync(path.join(nextRootAbs, rel), 'utf8'); } catch { return null; } })();
|
|
216
|
+
if (a !== b) changed.push(rel);
|
|
217
|
+
}
|
|
218
|
+
// skills/ and commands/ are boot-loaded markdown: any file-set or content difference counts.
|
|
219
|
+
for (const dir of ['skills', 'commands']) {
|
|
220
|
+
const list = (root) => { try { return fs.readdirSync(path.join(root, dir), { recursive: true }).sort().join('|'); } catch { return ''; } };
|
|
221
|
+
if (list(prevRootAbs) !== list(nextRootAbs)) changed.push(`${dir}/`);
|
|
222
|
+
}
|
|
223
|
+
return changed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function flip(version, codeRootAbs, why) {
|
|
227
|
+
const prev = readJSON(ACTIVE);
|
|
228
|
+
const prevRootAbs = prev?.codeRoot ? (path.isAbsolute(prev.codeRoot) ? prev.codeRoot : path.join(BRAIN_HOME, prev.codeRoot)) : null;
|
|
229
|
+
const shellChanged = prevRootAbs && fs.existsSync(prevRootAbs) ? shellDiff(prevRootAbs, codeRootAbs) : [];
|
|
230
|
+
const next = {
|
|
231
|
+
generation: (prev?.generation ?? 0) + 1,
|
|
232
|
+
version,
|
|
233
|
+
codeRoot: path.relative(BRAIN_HOME, codeRootAbs),
|
|
234
|
+
previous: prev ? { generation: prev.generation, version: prev.version, codeRoot: prev.codeRoot } : null,
|
|
235
|
+
shellChanged: shellChanged.length > 0,
|
|
236
|
+
shellChangedPaths: shellChanged,
|
|
237
|
+
flippedAt: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
writeAtomic(TXN, JSON.stringify({ state: 'flipping', from: prev?.version ?? null, to: version }));
|
|
240
|
+
writeAtomic(ACTIVE, JSON.stringify(next, null, 2));
|
|
241
|
+
writeAtomic(TXN, JSON.stringify({ state: 'active', from: prev?.version ?? null, to: version }));
|
|
242
|
+
try { fs.writeFileSync(SEEDED, next.flippedAt); } catch { /* marker best-effort */ }
|
|
243
|
+
receipt('SpineFlipped', { from: prev?.version ?? null, to: version, generation: next.generation, why });
|
|
244
|
+
console.log(`✓ spine flipped: ${prev?.version ?? '(none)'} → ${version} (generation ${next.generation}) — live on the next hook fire / MCP call`);
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function applyFromDir(srcDir, version, why) {
|
|
249
|
+
const active = readJSON(ACTIVE);
|
|
250
|
+
if (active?.version === version) {
|
|
251
|
+
const activeRoot = active.codeRoot
|
|
252
|
+
? (path.isAbsolute(active.codeRoot) ? active.codeRoot : path.join(BRAIN_HOME, active.codeRoot))
|
|
253
|
+
: null;
|
|
254
|
+
const problems = [];
|
|
255
|
+
if (!activeRoot || !fs.existsSync(activeRoot)) problems.push('active generation directory is missing');
|
|
256
|
+
try {
|
|
257
|
+
assertNoSymlinks(srcDir);
|
|
258
|
+
if (!problems.length) problems.push(...gateCandidate(activeRoot));
|
|
259
|
+
problems.push(...gateCandidate(srcDir));
|
|
260
|
+
if (!problems.length && payloadDigest(activeRoot) !== payloadDigest(srcDir)) {
|
|
261
|
+
problems.push('same version identifies different payload bytes — bump the version');
|
|
262
|
+
}
|
|
263
|
+
} catch (e) {
|
|
264
|
+
problems.push(String(e?.message || e));
|
|
265
|
+
}
|
|
266
|
+
if (problems.length) {
|
|
267
|
+
receipt('SameVersionRejected', { version, problems: problems.slice(0, 5) });
|
|
268
|
+
console.error(`✗ active ${version} cannot be treated as an idempotent retry: ${problems[0]}`);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
receipt('SameVersionNoop', { version, generation: active.generation, why });
|
|
272
|
+
console.log(`✓ spine already active: ${version} (generation ${active.generation}) — verified no-op`);
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
writeAtomic(TXN, JSON.stringify({ state: 'staging', to: version }));
|
|
276
|
+
const staging = stagePayload(srcDir, version);
|
|
277
|
+
const problems = gateCandidate(staging);
|
|
278
|
+
receipt('CandidateGated', { version, passed: problems.length === 0, problems: problems.slice(0, 5) });
|
|
279
|
+
if (problems.length) {
|
|
280
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
281
|
+
writeAtomic(TXN, JSON.stringify({ state: 'gate-failed', to: version, problems: problems.slice(0, 5) }));
|
|
282
|
+
console.error(`✗ candidate ${version} FAILED the gate — spine untouched, users stay on the working version:`);
|
|
283
|
+
for (const p of problems) console.error(` · ${p}`);
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
writeAtomic(TXN, JSON.stringify({ state: 'candidate-ready', to: version }));
|
|
287
|
+
const finalDir = promote(staging, version);
|
|
288
|
+
flip(version, finalDir, why);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── GC (finding 23): keep active + previous + fresh leases; collect the rest ────────────────────
|
|
293
|
+
function gc() {
|
|
294
|
+
const active = readJSON(ACTIVE);
|
|
295
|
+
const keep = new Set();
|
|
296
|
+
if (active?.codeRoot) keep.add(path.basename(active.codeRoot));
|
|
297
|
+
if (active?.previous?.codeRoot) keep.add(path.basename(active.previous.codeRoot));
|
|
298
|
+
if (fs.existsSync(LEASES)) {
|
|
299
|
+
for (const f of fs.readdirSync(LEASES)) {
|
|
300
|
+
const p = path.join(LEASES, f);
|
|
301
|
+
try {
|
|
302
|
+
if (Date.now() - fs.statSync(p).mtimeMs < LEASE_FRESH_MS) {
|
|
303
|
+
const lease = readJSON(p);
|
|
304
|
+
if (lease?.version) keep.add(lease.version);
|
|
305
|
+
} else fs.rmSync(p, { force: true });
|
|
306
|
+
} catch { /* skip */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
let removed = 0;
|
|
310
|
+
for (const d of fs.existsSync(VERSIONS) ? fs.readdirSync(VERSIONS) : []) {
|
|
311
|
+
if (!keep.has(d) && !d.includes('.staging-')) {
|
|
312
|
+
try { fs.rmSync(path.join(VERSIONS, d), { recursive: true, force: true }); removed++; } catch { /* busy */ }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
receipt('GcRan', { kept: [...keep], removed });
|
|
316
|
+
console.log(`gc: kept ${[...keep].join(', ') || '(none)'} · removed ${removed}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── sources ─────────────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
function newestStagedCC(expectedVersion = null) {
|
|
321
|
+
const semverish = (v) => v.split(/[.-]/).map((x) => (Number.isFinite(+x) ? +x : x));
|
|
322
|
+
const cmp = (a, b) => { const A = semverish(a), B = semverish(b); for (let i = 0; i < Math.max(A.length, B.length); i++) { if ((A[i] ?? 0) === (B[i] ?? 0)) continue; if (typeof A[i] === 'number' && typeof B[i] === 'number') return A[i] - B[i]; return String(A[i] ?? '') < String(B[i] ?? '') ? -1 : 1; } return 0; };
|
|
323
|
+
const candidates = [];
|
|
324
|
+
for (const cache of PLUGIN_CACHES) {
|
|
325
|
+
if (!fs.existsSync(cache)) continue;
|
|
326
|
+
for (const version of fs.readdirSync(cache)) {
|
|
327
|
+
if (!version.startsWith('.') && fs.existsSync(path.join(cache, version, 'scripts'))) {
|
|
328
|
+
const dir = path.join(cache, version);
|
|
329
|
+
const payloadVersion = versionOfPayload(dir, version);
|
|
330
|
+
if (!expectedVersion || payloadVersion === expectedVersion) candidates.push({ version: payloadVersion, dir });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
candidates.sort((a, b) => cmp(a.version, b.version));
|
|
335
|
+
return candidates.at(-1) || null;
|
|
336
|
+
}
|
|
337
|
+
function versionOfPayload(dir, fallback) {
|
|
338
|
+
const pj = readJSON(path.join(dir, '.claude-plugin', 'plugin.json'));
|
|
339
|
+
return pj?.version || fallback;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── modes ───────────────────────────────────────────────────────────────────────────────────────
|
|
343
|
+
function main() {
|
|
344
|
+
fs.mkdirSync(BRAIN_HOME, { recursive: true });
|
|
345
|
+
|
|
346
|
+
if (has('--doctor')) {
|
|
347
|
+
const active = readJSON(ACTIVE); const dev = readJSON(DEV); const txn = readJSON(TXN);
|
|
348
|
+
console.log('spine doctor —', BRAIN_HOME);
|
|
349
|
+
console.log(' active :', active ? `${active.version} (gen ${active.generation}, flipped ${active.flippedAt})` : 'NOT SEEDED — hooks run the frozen plugin fallback');
|
|
350
|
+
console.log(' prev :', active?.previous ? `${active.previous.version} (gen ${active.previous.generation})` : '(none)');
|
|
351
|
+
console.log(' dev :', dev ? `ON → ${dev.codeRoot}` : 'off');
|
|
352
|
+
console.log(' txn :', txn ? txn.state : '(clean)');
|
|
353
|
+
console.log(' staged :', (() => { const s = newestStagedCC(); return s ? `${s.version} in CC plugin cache` : '(none found)'; })());
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (has('--dev-off')) { fs.rmSync(DEV, { force: true }); receipt('DevOff', {}); console.log('dev mode OFF — active.json governs again'); return 0; }
|
|
358
|
+
if (has('--dev')) {
|
|
359
|
+
const target = path.resolve(val('--dev', path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'plugin')));
|
|
360
|
+
if (!fs.existsSync(path.join(target, 'scripts'))) { console.error(`✗ ${target} has no scripts/ — point --dev at a checkout's plugin/ dir`); return 1; }
|
|
361
|
+
writeAtomic(DEV, JSON.stringify({ codeRoot: target, setAt: new Date().toISOString() }, null, 2));
|
|
362
|
+
receipt('DevOn', { codeRoot: target });
|
|
363
|
+
console.log(`dev mode ON → ${target} (edits are live on save; --auto will never flip away; turn off: --dev-off)`);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (!acquireLock(argv.join(' '))) { console.log('another update is in progress — exiting (concurrency 1).'); return 0; }
|
|
368
|
+
try {
|
|
369
|
+
recoverTxn();
|
|
370
|
+
|
|
371
|
+
if (has('--rollback')) {
|
|
372
|
+
const active = readJSON(ACTIVE);
|
|
373
|
+
if (!active?.previous) { console.error('✗ nothing to roll back to (no previous generation recorded)'); return 1; }
|
|
374
|
+
const prevRoot = path.isAbsolute(active.previous.codeRoot) ? active.previous.codeRoot : path.join(BRAIN_HOME, active.previous.codeRoot);
|
|
375
|
+
if (!fs.existsSync(prevRoot)) { console.error(`✗ previous generation's payload is gone (${prevRoot}) — cannot roll back`); return 1; }
|
|
376
|
+
flip(active.previous.version, prevRoot, 'rollback');
|
|
377
|
+
receipt('RollbackFlipped', { to: active.previous.version });
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (has('--seed')) {
|
|
382
|
+
const src = process.env.CLAUDE_PLUGIN_ROOT && fs.existsSync(path.join(process.env.CLAUDE_PLUGIN_ROOT, 'scripts'))
|
|
383
|
+
? { dir: process.env.CLAUDE_PLUGIN_ROOT, version: versionOfPayload(process.env.CLAUDE_PLUGIN_ROOT, 'seeded') }
|
|
384
|
+
: (() => { const s = newestStagedCC(); return s ? { dir: s.dir, version: versionOfPayload(s.dir, s.version) } : null; })();
|
|
385
|
+
if (!src) { console.error('✗ nothing to seed from (no CLAUDE_PLUGIN_ROOT, no CC-staged version)'); return 1; }
|
|
386
|
+
return applyFromDir(src.dir, src.version, 'seed') ? 0 : 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (has('--from-dir')) {
|
|
390
|
+
const dir = path.resolve(val('--from-dir'));
|
|
391
|
+
if (!dir || !fs.existsSync(dir)) { console.error('✗ --from-dir requires an existing payload dir'); return 1; }
|
|
392
|
+
return applyFromDir(dir, versionOfPayload(dir, `dir-${Date.now()}`), 'from-dir') ? 0 : 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --auto (default): unattended — newest CC-staged version, only if newer than active.
|
|
396
|
+
const dev = readJSON(DEV);
|
|
397
|
+
if (dev) { receipt('AutoSkippedDev', {}); console.log('dev mode is ON — --auto never flips away from a checkout (finding 24).'); return 0; }
|
|
398
|
+
const expectedVersion = val('--expected-version', null);
|
|
399
|
+
const staged = newestStagedCC(expectedVersion);
|
|
400
|
+
const active = readJSON(ACTIVE);
|
|
401
|
+
if (!staged) {
|
|
402
|
+
if (expectedVersion) {
|
|
403
|
+
console.error(`✗ no staged host payload exactly matches expected version ${expectedVersion} — spine unchanged`);
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
console.log('no host-staged version found — nothing to apply.');
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
const stagedVersion = versionOfPayload(staged.dir, staged.version);
|
|
410
|
+
if (active && active.version === stagedVersion) { console.log(`already on ${stagedVersion} — nothing to do.`); return 0; }
|
|
411
|
+
receipt('UpdateChecked', { active: active?.version ?? null, staged: stagedVersion });
|
|
412
|
+
return applyFromDir(staged.dir, stagedVersion, active ? 'auto-update' : 'auto-seed') ? 0 : 1;
|
|
413
|
+
} finally {
|
|
414
|
+
releaseLock();
|
|
415
|
+
if (has('--gc') || !has('--no-gc')) { try { gc(); } catch { /* GC never fails the run */ } }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
process.exit(main());
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# verify-interface.sh — advisory migration notice for legacy raw-shell RuvNet CLI calls.
|
|
3
|
+
#
|
|
4
|
+
# Issue #48 retires the Bash PreToolUse wall. Reconstructing shell grammar from a raw command string
|
|
5
|
+
# accumulated false positives and false negatives across issues #12, #13, #41, and #44. A hook that
|
|
6
|
+
# guesses what a shell will execute cannot safely authorize or deny execution.
|
|
7
|
+
#
|
|
8
|
+
# The enforceable boundary now lives at the MCP protocol layer:
|
|
9
|
+
# 1. ruvnet_cli_help accepts a finite executable enum plus a structured subcommand argv.
|
|
10
|
+
# 2. ruvnet_cli_run accepts a literal argv array and launches with shell:false.
|
|
11
|
+
# 3. unknown executables and missing/stale successful-help stamps fail closed there.
|
|
12
|
+
#
|
|
13
|
+
# This legacy hook may point callers toward that boundary, but its permanent contract is exit 0.
|
|
14
|
+
|
|
15
|
+
set -uo pipefail
|
|
16
|
+
|
|
17
|
+
INPUT=""
|
|
18
|
+
while IFS= read -r -t 2 _line; do
|
|
19
|
+
INPUT+="$_line"
|
|
20
|
+
[ ${#INPUT} -ge 65536 ] && break
|
|
21
|
+
done
|
|
22
|
+
[ -n "${_line:-}" ] && INPUT+="$_line"
|
|
23
|
+
INPUT="${INPUT:0:65536}"
|
|
24
|
+
[ -n "$INPUT" ] || exit 0
|
|
25
|
+
|
|
26
|
+
# Preserve the existing explicit opt-in boundary. An assumed subscription profile is not consent to
|
|
27
|
+
# add even an advisory message to the user's tool stream.
|
|
28
|
+
PROFILE="${MODEL_ROUTER_PROFILE:-$HOME/.claude/model-router/profile.json}"
|
|
29
|
+
[ -f "$PROFILE" ] || exit 0
|
|
30
|
+
PROFILE_INPUT=""
|
|
31
|
+
while IFS= read -r _profile_line; do
|
|
32
|
+
PROFILE_INPUT+="$_profile_line"
|
|
33
|
+
[ ${#PROFILE_INPUT} -ge 65536 ] && break
|
|
34
|
+
done < "$PROFILE" 2>/dev/null || exit 0
|
|
35
|
+
[ -n "${_profile_line:-}" ] && PROFILE_INPUT+="$_profile_line"
|
|
36
|
+
case "$PROFILE_INPUT" in *'"basis"'*'"assumed:'*) exit 0 ;; esac
|
|
37
|
+
|
|
38
|
+
NODE_BIN=$(command -v node) || exit 0
|
|
39
|
+
HOOK_INPUT="$(dirname "${BASH_SOURCE[0]}")/hook-input.mjs"
|
|
40
|
+
TOOL_NAME=$(printf '%s' "$INPUT" | "$NODE_BIN" "$HOOK_INPUT" tool_name 2>/dev/null) || exit 0
|
|
41
|
+
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
|
42
|
+
CMD=$(printf '%s' "$INPUT" | "$NODE_BIN" "$HOOK_INPUT" command 2>/dev/null) || exit 0
|
|
43
|
+
[ -n "$CMD" ] || exit 0
|
|
44
|
+
|
|
45
|
+
# This deliberately broad substring check controls only whether guidance is shown. It grants or
|
|
46
|
+
# denies nothing, records no authority stamp, and cannot change the tool call's exit status.
|
|
47
|
+
case "$CMD" in
|
|
48
|
+
*ruflo*|*claude-flow*|*agentic-flow*|*agentic-qe*|*ruvector*|*agent-browser*|*ruv-swarm*) ;;
|
|
49
|
+
*) exit 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
|
|
52
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"Interface check advisory: prefer native Ruflo MCP tools. For CLI-only gaps, use ruvnet_cli_help and then ruvnet_cli_run with literal argv; raw Bash is never blocked by this notice."}}'
|
|
53
|
+
exit 0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# version-bump-gate.sh — PreToolUse gate on Bash. EVERY PUSH CARRIES A VERSION INCREMENT.
|
|
3
|
+
#
|
|
4
|
+
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
# WHY (2026-07-13). Stuart, after a push with no bump served him stale code: "Every single
|
|
6
|
+
# commit to GitHub should come with a version increment: major.minor.bugfix. It's the only way
|
|
7
|
+
# we're gonna know beyond a shadow of a doubt what's going on. When other things are looking to
|
|
8
|
+
# read a change in version to know that there's something they need to be aware of, that's not
|
|
9
|
+
# negotiable."
|
|
10
|
+
#
|
|
11
|
+
# THE INCIDENT: gates/fleet-doctor//savings were pushed under an unchanged 2.5.2. The plugin
|
|
12
|
+
# cache compared 2.5.2 == 2.5.2, correctly served the STALE copy, and a restarted session
|
|
13
|
+
# loaded without /savings. THE VERSION NUMBER IS THE UPDATE SIGNAL — a push without a bump is
|
|
14
|
+
# an update nothing can see.
|
|
15
|
+
#
|
|
16
|
+
# Prior art is rUv's own: agent-harness-generator preflight.mjs refuses to greenlight a release
|
|
17
|
+
# when package versions drift, and version-bump.mjs bumps every manifest in lockstep — the gate
|
|
18
|
+
# is the product. This is that discipline applied at the push boundary.
|
|
19
|
+
#
|
|
20
|
+
# CONTRACT: exit 0 = allow · exit 2 + stderr = BLOCK. FAILS OPEN on anything unparseable.
|
|
21
|
+
# Opt-in (router profile.json), bash builtins + git only — same hardening as its four siblings.
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
set -uo pipefail
|
|
25
|
+
|
|
26
|
+
INPUT=""
|
|
27
|
+
# BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
|
|
28
|
+
# opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
|
|
29
|
+
# harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
|
|
30
|
+
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
31
|
+
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
32
|
+
# the whole thing back at once and a per-iteration cap never fires.
|
|
33
|
+
while IFS= read -r -t 2 _l; do
|
|
34
|
+
INPUT+="$_l"
|
|
35
|
+
[ ${#INPUT} -ge 65536 ] && break
|
|
36
|
+
done
|
|
37
|
+
[ -n "$_l" ] && INPUT+="$_l"
|
|
38
|
+
INPUT="${INPUT:0:65536}"
|
|
39
|
+
[ -n "$INPUT" ] || exit 0
|
|
40
|
+
|
|
41
|
+
PROFILE="${MODEL_ROUTER_PROFILE:-$HOME/.claude/model-router/profile.json}"
|
|
42
|
+
[ -f "$PROFILE" ] || exit 0
|
|
43
|
+
[ "${RUVNET_SKIP_VERSION_GATE:-0}" = "1" ] && exit 0
|
|
44
|
+
|
|
45
|
+
field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
|
|
46
|
+
[ "$(field tool_name)" = "Bash" ] || exit 0
|
|
47
|
+
CMD=$(field command)
|
|
48
|
+
[[ $CMD == *"git push"* ]] || exit 0
|
|
49
|
+
|
|
50
|
+
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
51
|
+
|
|
52
|
+
# The version source of truth, in precedence order. A repo with neither is not version-managed
|
|
53
|
+
# by this convention — pass untouched.
|
|
54
|
+
SRC=""
|
|
55
|
+
for c in plugin/.claude-plugin/plugin.json package.json; do
|
|
56
|
+
[ -f "$ROOT/$c" ] && { SRC="$c"; break; }
|
|
57
|
+
done
|
|
58
|
+
[ -n "$SRC" ] || exit 0
|
|
59
|
+
|
|
60
|
+
UP=$(git -C "$ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || UP=origin/main
|
|
61
|
+
git -C "$ROOT" rev-parse --verify "$UP" >/dev/null 2>&1 || exit 0
|
|
62
|
+
|
|
63
|
+
AHEAD=$(git -C "$ROOT" rev-list --count "$UP..HEAD" 2>/dev/null) || exit 0
|
|
64
|
+
[ "$AHEAD" -gt 0 ] || exit 0 # nothing outgoing → nothing to gate
|
|
65
|
+
|
|
66
|
+
ver_at() { # ver_at <rev> — version string in $SRC at that rev, empty on any failure
|
|
67
|
+
local j re='"version"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
|
68
|
+
j=$(git -C "$ROOT" show "$1:$SRC" 2>/dev/null) || return 0
|
|
69
|
+
[[ $j =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"
|
|
70
|
+
}
|
|
71
|
+
V_HEAD=$(ver_at HEAD); V_UP=$(ver_at "$UP")
|
|
72
|
+
[ -n "$V_HEAD" ] && [ -n "$V_UP" ] || exit 0 # can't read either side → fail open
|
|
73
|
+
[ "$V_HEAD" != "$V_UP" ] && exit 0 # bumped → pass
|
|
74
|
+
|
|
75
|
+
# ── LESSON STORE CONSULTATION (ADR-030 L3, wired 2026-07-22) ────────────────────────────────────
|
|
76
|
+
# This gate hardcoded its own message for months while lesson L05 — the identical rule, recorded 52
|
|
77
|
+
# times across 4 projects — sat in a store that nothing read. A grep for `lessonsFor` across every
|
|
78
|
+
# gate returned zero: lessons were mined, weighted, trust-boundaried, and consumed by nobody.
|
|
79
|
+
#
|
|
80
|
+
# Now the store speaks here. Today every lesson is an unratified candidate, so this ADDS the lesson's
|
|
81
|
+
# own words and evidence to the refusal below rather than changing whether it fires. The day the
|
|
82
|
+
# owner ratifies L05, the same wire starts refusing on the lesson's authority instead of on this
|
|
83
|
+
# script's hardcoded copy — with no change to this file. Enforcement is data, not a rewrite.
|
|
84
|
+
#
|
|
85
|
+
# Fails open by construction: any error, missing store, or absent node exits this block silently. A
|
|
86
|
+
# gate that blocks a push because it could not read a config file is a gate people switch off.
|
|
87
|
+
LESSON_GATE="$(cd "$(dirname "$0")/../.." 2>/dev/null && pwd)/scripts/lesson-gate.mjs"
|
|
88
|
+
if [ -f "$LESSON_GATE" ] && command -v node >/dev/null 2>&1; then
|
|
89
|
+
LESSON_OUT="$(node "$LESSON_GATE" --trigger ship 2>/dev/null || true)"
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
read -r -d '' MSG <<EOF || true
|
|
93
|
+
⛔ BLOCKED — this push carries $AHEAD commit(s) but NO version increment ($SRC still $V_UP).
|
|
94
|
+
|
|
95
|
+
THE VERSION NUMBER IS THE UPDATE SIGNAL. Plugin caches, update checks, and other sessions decide
|
|
96
|
+
whether to pull fresh code by comparing versions — a push without a bump is an update nothing can
|
|
97
|
+
see. This exact miss served a restarted session stale 2.5.2 without /savings on 2026-07-13.
|
|
98
|
+
|
|
99
|
+
Before pushing:
|
|
100
|
+
1. bump "version" in $SRC (major.minor.bugfix)
|
|
101
|
+
2. node scripts/sync-version.mjs # syncs every surface, --check must pass
|
|
102
|
+
3. include the bump in the outgoing commits, then push
|
|
103
|
+
|
|
104
|
+
(Stuart, 2026-07-13: "that's not negotiable." Deliberate override, say why out loud:
|
|
105
|
+
RUVNET_SKIP_VERSION_GATE=1)
|
|
106
|
+
${LESSON_OUT:+
|
|
107
|
+
── from your own lesson store ─────────────────────────────────────────────
|
|
108
|
+
$LESSON_OUT}
|
|
109
|
+
EOF
|
|
110
|
+
bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" version-bump-gate "push" "commits carried no version bump — an update nothing can see" 2>/dev/null || true
|
|
111
|
+
printf '%s\n' "$MSG" >&2
|
|
112
|
+
exit 2
|