ruvnet-brain 4.3.28 → 4.3.30
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 +1 -1
- package/bin/install.mjs +90 -8
- package/console/app.js +7 -2
- package/kb/corpus-release-identity.mjs +73 -0
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +83 -9
- package/plugin/scripts/advocacy-route.mjs +4 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +4 -0
- package/plugin/scripts/continuation-gate.mjs +71 -0
- package/plugin/scripts/decision-gate.mjs +29 -57
- package/plugin/scripts/ground-ruvnet.sh +77 -4
- package/plugin/scripts/grounding-stamp.sh +34 -5
- package/plugin/scripts/grounding-turn-gate.mjs +8 -2
- package/plugin/scripts/grounding-turn-mark.mjs +6 -1
- package/plugin/scripts/hook-input.mjs +54 -0
- package/plugin/scripts/hook-shim.mjs +10 -0
- package/plugin/scripts/memory-doctor.mjs +43 -0
- package/plugin/scripts/nightly-controller.mjs +6 -1
- package/plugin/scripts/project-progression-contract.mjs +17 -0
- package/plugin/scripts/project-progression-hook.mjs +18 -5
- package/plugin/scripts/project-progression-producer.mjs +16 -12
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +17 -0
- package/plugin/scripts/session-start-core.mjs +11 -5
- package/plugin/scripts/session-start-update-plane.mjs +5 -1
- package/plugin/scripts/unprompted-runtime.mjs +29 -8
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +1 -1
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/calibrate-router.mjs +6 -6
- package/scripts/corpus-reconcile.mjs +32 -3
- package/scripts/correction-detect.mjs +10 -11
- package/scripts/dispatch-receipt.mjs +2 -2
- package/scripts/dual-host-deliberation.mjs +3 -2
- package/scripts/execution-policy.mjs +10 -2
- package/scripts/gen-console-images.mjs +0 -1
- package/scripts/gen-images.mjs +0 -4
- package/scripts/host-install-matrix.mjs +7 -4
- package/scripts/independent-review-receipt.mjs +7 -8
- package/scripts/ingest-repo.mjs +45 -1
- package/scripts/learning-replay-cli.mjs +2 -1
- package/scripts/learning-replay-fixture.mjs +2 -1
- package/scripts/learnings.mjs +19 -5
- package/scripts/lesson-migrate-agentdb.mjs +635 -0
- package/scripts/loop-checkpoint.mjs +37 -1
- package/scripts/metaharness-receipts.mjs +3 -2
- package/scripts/nightly-two-run-proof.mjs +6 -6
- package/scripts/nightly-watchdog.mjs +10 -2
- package/scripts/onboarding-console.mjs +98 -49
- package/scripts/oracle/producer-hosts.mjs +2 -1
- package/scripts/private-overlay.mjs +31 -9
- package/scripts/public-verification-aggregate.mjs +4 -3
- package/scripts/publication-receipt.mjs +9 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/rebuild-gists-from-receipts.mjs +1 -1
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/release.mjs +34 -82
- package/scripts/retrieval-canary.mjs +3 -2
- package/scripts/review-model-defaults.mjs +26 -0
- package/scripts/route-cheap.mjs +20 -7
- package/scripts/router-utilization.mjs +5 -5
- package/scripts/rvf-generation.mjs +68 -2
- package/scripts/single-source-check.mjs +270 -0
- package/scripts/subscription-hosts.mjs +4 -0
- package/scripts/sync-version.mjs +22 -0
- package/scripts/trismart.mjs +3 -3
- package/scripts/wired-check.mjs +40 -4
- package/console/assets/memory.webp +0 -0
- package/plugin/scripts/version-bump-gate.sh +0 -124
- package/scripts/qe/aggregate-4.3.mjs +0 -42
- package/scripts/release-convergence-watchdog.mjs +0 -112
- package/scripts/stamp-existing-rvf-generations.mjs +0 -53
- package/scripts/verify-channels.mjs +0 -196
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/single-source-check.mjs — the single-source contract, as executable checks.
|
|
3
|
+
//
|
|
4
|
+
// Every entry is one defect found in the 2026-09-26 audit: two or more versions of the same rule,
|
|
5
|
+
// process, code path or fact. Each check's exit is the verdict — nobody's word is. `repo` checks run
|
|
6
|
+
// in CI (canonical-qa) so a contradiction that is removed can never silently come back; `machine`
|
|
7
|
+
// checks describe this maintainer Mac and run with --machine.
|
|
8
|
+
//
|
|
9
|
+
// node scripts/single-source-check.mjs # repo checks (CI)
|
|
10
|
+
// node scripts/single-source-check.mjs --machine # repo + machine checks
|
|
11
|
+
// node scripts/single-source-check.mjs --json
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { readCheckpoint, checkpointStaleness } from './loop-checkpoint.mjs';
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const HOME = os.homedir();
|
|
21
|
+
const MACHINE = process.argv.includes('--machine');
|
|
22
|
+
const JSON_OUT = process.argv.includes('--json');
|
|
23
|
+
|
|
24
|
+
const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 }).trim();
|
|
25
|
+
const tracked = git('ls-files').split('\n').filter(Boolean);
|
|
26
|
+
const read = (f) => { try { return readFileSync(path.join(ROOT, f), 'utf8'); } catch { return ''; } };
|
|
27
|
+
const readAbs = (f) => { try { return readFileSync(f, 'utf8'); } catch { return ''; } };
|
|
28
|
+
const json = (s) => { try { return JSON.parse(s); } catch { return {}; } };
|
|
29
|
+
|
|
30
|
+
// Instruction surfaces: files that tell a person or an agent HOW to operate this project.
|
|
31
|
+
// Decision records (ADR/DDD), research notes and append-only logs are history and are excluded.
|
|
32
|
+
const HISTORY = /^(docs\/(adr|ddd|research|audits|reviews|qe)\/|CHANGELOG\.md$|PROGRESS\.md$|plugin\/docs\/RELEASE-NOTES|\.release-evidence\/|tests\/|evals\/)/;
|
|
33
|
+
// kb/ is corpus content (primers/cards describing OTHER projects' own commands) and docs/issues/
|
|
34
|
+
// are upstream bug reports quoting other configs — both are data, not instructions to this project.
|
|
35
|
+
const instructions = tracked.filter((f) => /\.md$/.test(f) && !HISTORY.test(f) && !/^(kb|docs\/issues)\//.test(f));
|
|
36
|
+
const grepIn = (files, re) => files.flatMap((f) => read(f).split('\n')
|
|
37
|
+
.map((l, i) => (re.test(l) ? `${f}:${i + 1}: ${l.trim().slice(0, 140)}` : null)).filter(Boolean));
|
|
38
|
+
const none = (hits) => ({ ok: hits.length === 0, detail: hits.slice(0, 8).join('\n') || 'none' });
|
|
39
|
+
|
|
40
|
+
const pkg = json(read('package.json'));
|
|
41
|
+
const workflows = tracked.filter((f) => /^\.github\/workflows\/.+\.ya?ml$/.test(f));
|
|
42
|
+
|
|
43
|
+
const checks = [
|
|
44
|
+
// A — one copy of the code
|
|
45
|
+
{ id: 'A1', area: 'code', scope: 'repo', title: 'Every module is wired or has a stated reason (the repo\'s own wired-check audit)',
|
|
46
|
+
run: () => { const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts/wired-check.mjs'), '--check'], { cwd: ROOT, encoding: 'utf8' });
|
|
47
|
+
return { ok: r.status === 0, detail: `${r.stdout}${r.stderr}`.split('\n').filter((l) => /UNWIRED|✗/.test(l)).slice(0, 6).join('\n') }; } },
|
|
48
|
+
{ id: 'A2', area: 'code', scope: 'repo', title: 'No byte-identical duplicate files (outside test fixtures)',
|
|
49
|
+
run: () => {
|
|
50
|
+
const seen = new Map(); const dup = [];
|
|
51
|
+
for (const f of tracked) {
|
|
52
|
+
if (!/\.(mjs|js|cjs|sh|md|yml|json)$/.test(f) || /^tests\/|fixtures?\//.test(f)) continue;
|
|
53
|
+
if (f.endsWith('RELEASE-NOTES-4.0.md')) continue; // intentional pair, equality enforced by tests/integration/whats-new-installed.test.mjs
|
|
54
|
+
const t = read(f); if (t.length < 200) continue;
|
|
55
|
+
if (seen.has(t)) dup.push(`${f} == ${seen.get(t)}`); else seen.set(t, f);
|
|
56
|
+
}
|
|
57
|
+
return none(dup);
|
|
58
|
+
} },
|
|
59
|
+
|
|
60
|
+
// B — one version of the rules
|
|
61
|
+
{ id: 'B1', area: 'rules', scope: 'repo', title: 'CONTRIBUTING.md is the only operating rulebook (no parallel process docs)',
|
|
62
|
+
run: () => none(['docs/QA-RELEASE-PROCESS.md', 'docs/NIGHTLY-REFRESH.md'].filter((f) => tracked.includes(f))) },
|
|
63
|
+
{ id: 'B2', area: 'rules', scope: 'repo', title: 'No instruction tells anyone to publish locally',
|
|
64
|
+
run: () => none(grepIn(instructions.filter((f) => f !== 'CONTRIBUTING.md'), /release\.mjs --publish|\bnpm publish\b|gh release create/)
|
|
65
|
+
.filter((l) => !/\b(never|not|unusable|refus|intentionally|blocked|cannot|forbid|only through|provenance|ahead of the last)/i.test(l))) },
|
|
66
|
+
{ id: 'B3', area: 'rules', scope: 'repo', title: 'No instruction uses npx for ruflo / claude-flow (one global ruflo)',
|
|
67
|
+
run: () => none(grepIn(instructions, /npx (-y )?(@claude-flow|claude-flow|ruflo)\b/)
|
|
68
|
+
.filter((l) => !/\bnever\b|console\/CONTRACT\.md:\d+: +"event"/i.test(l))) },
|
|
69
|
+
{ id: 'B4', area: 'rules', scope: 'repo', title: 'No live file cites a workflow that does not exist',
|
|
70
|
+
run: () => {
|
|
71
|
+
const exists = new Set(workflows.map((w) => path.basename(w)));
|
|
72
|
+
const live = [...instructions, ...workflows, ...tracked.filter((f) => /^(scripts|plugin|bin|kb)\/.+\.(mjs|sh)$/.test(f))];
|
|
73
|
+
return none(live.flatMap((f) => [...read(f).matchAll(/\b([a-z0-9-]+\.ya?ml)\b/g)].map((m) => m[1])
|
|
74
|
+
.filter((n) => /release|corpus|publish|nightly|preflight|grading/.test(n) && !exists.has(n))
|
|
75
|
+
.map((n) => `${f}: ${n}`)));
|
|
76
|
+
} },
|
|
77
|
+
{ id: 'B5', area: 'rules', scope: 'repo', title: 'Every `npm run X` in an instruction exists in package.json',
|
|
78
|
+
run: () => {
|
|
79
|
+
const scripts = new Set([...Object.keys(pkg.scripts || {}), ...Object.keys(json(read('kb/package.json')).scripts || {})]);
|
|
80
|
+
return none(instructions.flatMap((f) => [...read(f).matchAll(/npm run ([a-z0-9:_-]+)/g)]
|
|
81
|
+
.filter((m) => !scripts.has(m[1])).map((m) => `${f}: npm run ${m[1]}`)));
|
|
82
|
+
} },
|
|
83
|
+
{ id: 'B6', area: 'rules', scope: 'repo', title: 'No instruction grants standing authority to publish without the owner',
|
|
84
|
+
run: () => none(grepIn(instructions, /standing authori[sz]ation permits|no separate human reviewer click/i)) },
|
|
85
|
+
{ id: 'B7', area: 'rules', scope: 'repo', title: 'Model IDs are selected only in their owner modules',
|
|
86
|
+
run: () => {
|
|
87
|
+
const re = /['"`](claude-(fable|opus|sonnet|haiku)-[0-9][a-z0-9.-]*|gpt-[0-9][a-z0-9.-]*)['"`]/;
|
|
88
|
+
// Owners (the single definition sites) and non-selecting mentions, each with its reason.
|
|
89
|
+
const allowed = new Map([
|
|
90
|
+
['scripts/review-model-defaults.mjs', 'owner: review / dual-host defaults'],
|
|
91
|
+
['scripts/route-cheap.mjs', 'owner: Claude cost ladder'],
|
|
92
|
+
['plugin/scripts/identifier-preflight.mjs', 'doc comment quoting a historical incident'],
|
|
93
|
+
['scripts/goldie-research.mjs', 'family prefix match against the live catalog'],
|
|
94
|
+
['scripts/routing-flywheel.mjs', 'comment example of id normalisation'],
|
|
95
|
+
['scripts/proxy/proxy-verify.mjs', 'standalone ADR-0026 proxy trial tool (wired-check exempt)'],
|
|
96
|
+
]);
|
|
97
|
+
const files = tracked.filter((f) => /^(scripts|plugin|bin|kb)\/.+\.mjs$/.test(f) && !allowed.has(f) && re.test(read(f)));
|
|
98
|
+
return none(files);
|
|
99
|
+
} },
|
|
100
|
+
{ id: 'B8', area: 'rules', scope: 'repo', title: 'ADRs governing the live corpus publisher are not left "Proposed"',
|
|
101
|
+
run: () => none(tracked.filter((f) => /^docs\/adr\/008[56]-/.test(f)).filter((f) => /^status:\s*Proposed/im.test(read(f)))) },
|
|
102
|
+
{ id: 'B9', area: 'rules', scope: 'repo', title: 'All version surfaces carry one version',
|
|
103
|
+
run: () => {
|
|
104
|
+
const v = [['package.json', pkg.version], ['kb/package.json', json(read('kb/package.json')).version],
|
|
105
|
+
['plugin/.claude-plugin/plugin.json', json(read('plugin/.claude-plugin/plugin.json')).version],
|
|
106
|
+
['plugin/.codex-plugin/plugin.json', json(read('plugin/.codex-plugin/plugin.json')).version]].filter(([, x]) => x);
|
|
107
|
+
return { ok: new Set(v.map(([, x]) => x)).size === 1, detail: v.map(([f, x]) => `${f}=${x}`).join(' ') };
|
|
108
|
+
} },
|
|
109
|
+
{ id: 'B10', area: 'rules', scope: 'repo', title: 'Public pages show exactly one current version (no candidate/public split)',
|
|
110
|
+
run: () => none(grepIn(['README.md', 'explainer/index.html'], /candidate preview|public npm v\d/i)) },
|
|
111
|
+
{ id: 'B11', area: 'rules', scope: 'repo', title: 'The ADR index lists every ADR, with the status the ADR itself declares',
|
|
112
|
+
run: () => {
|
|
113
|
+
const idx = read('docs/adr/README.md');
|
|
114
|
+
const adrs = tracked.filter((f) => /^docs\/adr\/\d{4}-.+\.md$/.test(f));
|
|
115
|
+
const missing = adrs.filter((f) => !idx.includes(path.basename(f))).map((f) => `not indexed: ${f}`);
|
|
116
|
+
const drift = adrs.flatMap((f) => {
|
|
117
|
+
const own = (read(f).match(/^status:\s*([A-Za-z]+)/m) || [])[1];
|
|
118
|
+
const statusTable = idx.slice(Math.max(0, idx.indexOf('| ADR | Title | Status |')));
|
|
119
|
+
const row = statusTable.split('\n').find((l) => l.includes(`(${path.basename(f)})`));
|
|
120
|
+
const listed = row && (row.match(/\|\s*([A-Za-z]+)[^|]*\|\s*$/) || [])[1];
|
|
121
|
+
return own && listed && own.toLowerCase() !== listed.toLowerCase() ? [`${path.basename(f)}: ADR says ${own}, index says ${listed}`] : [];
|
|
122
|
+
});
|
|
123
|
+
return none([...missing, ...drift]);
|
|
124
|
+
} },
|
|
125
|
+
|
|
126
|
+
{ id: 'B12', area: 'rules', scope: 'machine', title: 'The local (untracked) CLAUDE.md / AGENTS.md carry no contradicting instruction',
|
|
127
|
+
run: () => {
|
|
128
|
+
const main = path.join(HOME, 'Code/ruvnet-brain');
|
|
129
|
+
const bad = /npx (-y )?(@claude-flow|claude-flow|ruflo)\b|standing authori[sz]ation permits|no separate human reviewer click|npm run (build|dev|test:integration|test:coverage|test:security)\b|not direct Agent tool/i;
|
|
130
|
+
return none(['CLAUDE.md', 'AGENTS.md'].flatMap((f) => readAbs(path.join(main, f)).split('\n')
|
|
131
|
+
.map((l, i) => (bad.test(l) ? `${f}:${i + 1}: ${l.trim().slice(0, 120)}` : null)).filter(Boolean)));
|
|
132
|
+
} },
|
|
133
|
+
|
|
134
|
+
// C — one release path
|
|
135
|
+
{ id: 'C1', area: 'release', scope: 'repo', title: 'Unattended corpus promotion stays disarmed until a code release is install-verified',
|
|
136
|
+
run: () => ({ ok: !tracked.includes('data/approved-runtime.json'), detail: tracked.includes('data/approved-runtime.json') ? 'data/approved-runtime.json present (arms the 07:17 UTC nightly)' : 'absent' }) },
|
|
137
|
+
{ id: 'C2', area: 'release', scope: 'repo', title: 'Every scheduled workflow pages the phone on failure',
|
|
138
|
+
run: () => {
|
|
139
|
+
const alerts = read('.github/workflows/ntfy-alerts.yml');
|
|
140
|
+
return none(workflows.filter((w) => /^\s*schedule:/m.test(read(w)) && !w.endsWith('ntfy-alerts.yml'))
|
|
141
|
+
.map((w) => ((read(w).match(/^name:\s*(.+)$/m) || [])[1] || w).trim().replace(/['"]/g, ''))
|
|
142
|
+
.filter((n) => !alerts.includes(n)));
|
|
143
|
+
} },
|
|
144
|
+
{ id: 'C4', area: 'release', scope: 'machine', title: 'The corpus gist job has its authenticated token (RUVNET_GISTS_TOKEN)',
|
|
145
|
+
run: () => { const r = spawnSync('gh', ['secret', 'list', '-R', 'stuinfla/ruvnet-brain'], { encoding: 'utf8' }); return { ok: /^RUVNET_GISTS_TOKEN\b/m.test(r.stdout), detail: r.stdout.split('\n').map((l) => l.split(/\s/)[0]).filter(Boolean).join(', ') }; } },
|
|
146
|
+
{ id: 'C3', area: 'release', scope: 'machine', title: 'Publishing requires the owner: Production environment has a required reviewer',
|
|
147
|
+
run: () => {
|
|
148
|
+
const r = spawnSync('gh', ['api', 'repos/stuinfla/ruvnet-brain/environments', '-q',
|
|
149
|
+
'.environments[]|select(.name=="Production – ruvnet-brain")|[.protection_rules[]?|select(.type=="required_reviewers")]|length'], { encoding: 'utf8' });
|
|
150
|
+
return { ok: r.status === 0 && Number(r.stdout.trim()) > 0, detail: `required_reviewers rules: ${r.stdout.trim() || r.stderr.trim()}` };
|
|
151
|
+
} },
|
|
152
|
+
|
|
153
|
+
// D — one corpus / update path
|
|
154
|
+
{ id: 'D1', area: 'corpus', scope: 'repo', title: 'The end-user updater label is defined once; the one necessary copy (standalone runner) matches it',
|
|
155
|
+
run: () => {
|
|
156
|
+
const owner = (read('plugin/scripts/nightly-scheduler.mjs').match(/NIGHTLY_LABEL\s*=\s*'([^']+)'/) || [])[1];
|
|
157
|
+
const copies = tracked.filter((f) => /\.(mjs|sh)$/.test(f) && !/^tests\//.test(f) && f !== 'plugin/scripts/nightly-scheduler.mjs')
|
|
158
|
+
.filter((f) => /\^com\\\.ruvnet\\\.brain-update|'com\.ruvnet\.brain-update'/.test(read(f)));
|
|
159
|
+
// bin/nightly-refresh.mjs is copied to a standalone content-addressed path and executed outside the package, so it cannot import.
|
|
160
|
+
const extra = copies.filter((f) => f !== 'bin/nightly-refresh.mjs');
|
|
161
|
+
// The runner MUST carry the owner's exact label (it validates its own registration against it);
|
|
162
|
+
// testing only files that already contain the label would miss exactly the drifted case.
|
|
163
|
+
const runner = read('bin/nightly-refresh.mjs');
|
|
164
|
+
const esc = owner.replace(/\./g, '\\.');
|
|
165
|
+
const drift = !owner ? ['owner label not found in plugin/scripts/nightly-scheduler.mjs']
|
|
166
|
+
: (!runner.includes(`'${owner}'`) || !runner.includes(`/^${esc}\\.proof-`)) ? [`bin/nightly-refresh.mjs does not carry owner label ${owner}`] : [];
|
|
167
|
+
return none([...extra, ...drift]);
|
|
168
|
+
} },
|
|
169
|
+
|
|
170
|
+
{ id: 'D2', area: 'corpus', scope: 'machine', title: 'Installed knowledge matches the installed plugin version',
|
|
171
|
+
run: () => {
|
|
172
|
+
const tag = String(json(readAbs(path.join(HOME, '.cache/ruvnet-brain/kb/SOURCE.json'))).releaseTag || '').replace(/^v/, '');
|
|
173
|
+
const active = json(readAbs(path.join(HOME, '.cache/ruvnet-brain/active.json'))).version;
|
|
174
|
+
return { ok: !!tag && tag === active, detail: `knowledge ${tag || 'unknown'} vs plugin ${active || 'unknown'}` };
|
|
175
|
+
} },
|
|
176
|
+
{ id: 'D3', area: 'corpus', scope: 'machine', title: 'Exactly one update owner exists, and its last refresh run passed within 26h',
|
|
177
|
+
run: () => {
|
|
178
|
+
const brainJob = spawnSync('launchctl', ['print', `gui/${process.getuid()}/com.ruvnet.brain-update`], { encoding: 'utf8' }).status === 0;
|
|
179
|
+
const akJob = spawnSync('launchctl', ['print', `gui/${process.getuid()}/com.stuartkerr.ak-sync`], { encoding: 'utf8' }).status === 0;
|
|
180
|
+
const dir = path.join(HOME, '.cache/ruvnet-brain/refresh-runs');
|
|
181
|
+
const runs = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.json')).sort() : [];
|
|
182
|
+
const last = runs.length ? json(readAbs(path.join(dir, runs.at(-1)))) : {};
|
|
183
|
+
const fresh = last.startedAt && (Date.now() - Date.parse(last.startedAt)) < 26 * 3600e3;
|
|
184
|
+
const owners = [brainJob && 'brain-update', akJob && 'ak-sync'].filter(Boolean);
|
|
185
|
+
return { ok: owners.length === 1 && fresh && last.terminalVerdict !== 'failed', detail: `owners=[${owners.join(',') || 'none'}] last run ${last.startedAt || 'none'} verdict ${last.terminalVerdict || last.status || 'none'}` };
|
|
186
|
+
} },
|
|
187
|
+
|
|
188
|
+
// E — one hook / context plane
|
|
189
|
+
{ id: 'E1', area: 'hooks', scope: 'machine', title: 'No stale autonomous-loop checkpoint is injected into sessions',
|
|
190
|
+
// H3: judged by the checkpoint's OWN `updatedAt` claim (loop-checkpoint.mjs's checkpointStaleness
|
|
191
|
+
// — the SAME rule ground-ruvnet.sh's `stale` verb uses), never by file mtime. A copy/rsync/restore
|
|
192
|
+
// resets mtime without the checkpoint's content changing, which is exactly the kind of second,
|
|
193
|
+
// drifting definition of "stale" this audit exists to catch — including, previously, in itself.
|
|
194
|
+
run: () => {
|
|
195
|
+
const f = path.join(ROOT, '.ruvnet-brain/checkpoint.json');
|
|
196
|
+
const main = path.join(HOME, 'Code/ruvnet-brain/.ruvnet-brain/checkpoint.json');
|
|
197
|
+
const stale = [f, main].filter((p) => existsSync(p) && checkpointStaleness(readCheckpoint(p)).stale);
|
|
198
|
+
return none(stale);
|
|
199
|
+
} },
|
|
200
|
+
{ id: 'E2', area: 'hooks', scope: 'machine', title: 'Only one hook writes session snapshots (no global + plugin double writer)',
|
|
201
|
+
run: () => { const s = readAbs(path.join(HOME, '.claude/settings.json')); return { ok: !/agentdb-autocapture/.test(s), detail: /agentdb-autocapture/.test(s) ? 'global agentdb-autocapture still registered alongside the plugin session-snapshot' : 'single writer' }; } },
|
|
202
|
+
|
|
203
|
+
{ id: 'E3', area: 'hooks', scope: 'machine', title: 'Lessons live in one store (no .swarm lesson-* rows outside the plugin lesson store)',
|
|
204
|
+
run: () => { const r = spawnSync('ruflo', ['memory', 'list', '--path', path.join(HOME, 'Code/ruvnet-brain/.swarm/memory.db'), '--limit', '500'], { encoding: 'utf8', timeout: 60000 });
|
|
205
|
+
const n = (r.stdout.match(/lesson-/g) || []).length; return { ok: n === 0, detail: `${n} lesson-* rows in project .swarm (plugin store: ~/.config/ruvnet-brain/lessons.json)` }; } },
|
|
206
|
+
{ id: 'E4', area: 'hooks', scope: 'machine', title: 'One session-start continuity restorer (global hook stands down when the plugin is enabled)',
|
|
207
|
+
run: () => { const s = readAbs(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')); return { ok: s.includes('SINGLE CONTINUITY OWNER'), detail: 'agentdb-ensure.sh must stand down its project-state recall' }; } },
|
|
208
|
+
|
|
209
|
+
// F — this Mac
|
|
210
|
+
{ id: 'F1', area: 'machine', scope: 'machine', title: 'No plaintext API keys in ~/.claude/settings.json',
|
|
211
|
+
run: () => none(readAbs(path.join(HOME, '.claude/settings.json')).split('\n')
|
|
212
|
+
.filter((l) => /(api-key-|sk-[A-Za-z0-9]{20,}|_TOKEN"\s*:\s*"[^"$]{16,})/.test(l)).map((l) => l.replace(/:\s*".*"/, ': "<redacted>"').trim())) },
|
|
213
|
+
{ id: 'F2', area: 'machine', scope: 'machine', title: 'Every required scheduled job proves it ran (repo watchdog)',
|
|
214
|
+
run: () => { const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts/nightly-watchdog.mjs')], { cwd: ROOT, encoding: 'utf8', timeout: 60000 }); return { ok: r.status === 0, detail: `${r.stdout}${r.stderr}`.trim().split('\n').slice(-6).join('\n') }; } },
|
|
215
|
+
{ id: 'F3', area: 'machine', scope: 'machine', title: 'Scheduled jobs use the one Node the shell uses (no /usr/local/bin/node v22)',
|
|
216
|
+
run: () => {
|
|
217
|
+
const dir = path.join(HOME, 'Library/LaunchAgents');
|
|
218
|
+
const plists = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.plist')) : [];
|
|
219
|
+
return none(plists.filter((f) => readAbs(path.join(dir, f)).includes('/usr/local/bin/node')));
|
|
220
|
+
} },
|
|
221
|
+
{ id: 'F4', area: 'machine', scope: 'machine', title: 'Permission allow-list names the real Ruflo MCP server',
|
|
222
|
+
run: () => { const s = readAbs(path.join(HOME, '.claude/settings.json')); return { ok: !s.includes('"mcp__ruflo__*"') || s.includes('"mcp__claude-flow__*"'), detail: 'allow list names mcp__ruflo__* but the server is claude-flow' }; } },
|
|
223
|
+
|
|
224
|
+
// G — one release state
|
|
225
|
+
{ id: 'G1', area: 'state', scope: 'machine', title: 'Local main is exactly origin/main',
|
|
226
|
+
run: () => { const m = git('rev-parse', 'main'); const o = git('rev-parse', 'origin/main'); return { ok: m === o, detail: `main ${m.slice(0, 8)} origin ${o.slice(0, 8)}` }; } },
|
|
227
|
+
{ id: 'G2', area: 'state', scope: 'machine', title: 'npm latest, GitHub latest release and main carry the same version',
|
|
228
|
+
run: () => {
|
|
229
|
+
const npm = spawnSync('npm', ['view', 'ruvnet-brain', 'dist-tags.latest'], { encoding: 'utf8' }).stdout.trim();
|
|
230
|
+
const gh = spawnSync('gh', ['api', 'repos/stuinfla/ruvnet-brain/releases/latest', '-q', '.tag_name'], { encoding: 'utf8' }).stdout.trim().replace(/^v/, '');
|
|
231
|
+
const main = json(git('show', 'origin/main:package.json')).version;
|
|
232
|
+
return { ok: npm === gh && gh === main, detail: `npm ${npm} · github ${gh} · main ${main}` };
|
|
233
|
+
} },
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
const selected = checks.filter((c) => c.scope === 'repo' || MACHINE);
|
|
237
|
+
const results = selected.map((c) => { let r; try { r = c.run(); } catch (e) { r = { ok: false, detail: `check crashed: ${e.message}` }; } return { ...c, ...r }; });
|
|
238
|
+
|
|
239
|
+
// R1 — the register may not claim more than the evidence shows. A row in docs/WORK-REGISTER.md whose
|
|
240
|
+
// State is LIVE or STAGED must have every check it names passing (machine checks only in --machine).
|
|
241
|
+
{
|
|
242
|
+
const byId = new Map(results.map((r) => [r.id, r]));
|
|
243
|
+
const scopeOf = new Map(checks.map((c) => [c.id, c.scope]));
|
|
244
|
+
const bad = [];
|
|
245
|
+
for (const line of read('docs/WORK-REGISTER.md').split('\n')) {
|
|
246
|
+
const cells = line.split('|').map((x) => x.trim());
|
|
247
|
+
if (cells.length < 7 || !/^\d+$/.test(cells[1])) continue;
|
|
248
|
+
const [, num, family, , state, checkList] = cells;
|
|
249
|
+
if (!/^(LIVE|STAGED)$/.test(state)) continue;
|
|
250
|
+
const ids = checkList.split(/[ ,]+/).filter(Boolean);
|
|
251
|
+
if (!ids.length) bad.push(`#${num} ${family}: claims ${state} but names no check`);
|
|
252
|
+
for (const id of ids) {
|
|
253
|
+
if (!scopeOf.has(id)) { bad.push(`#${num}: unknown check ${id}`); continue; }
|
|
254
|
+
if (scopeOf.get(id) === 'machine' && !MACHINE) continue;
|
|
255
|
+
const r = byId.get(id);
|
|
256
|
+
if (!r?.ok) bad.push(`#${num} ${family}: claims ${state} but ${id} fails`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
results.push({ id: 'R1', area: 'register', scope: 'repo', title: 'The work register claims nothing its checks do not prove', ok: bad.length === 0, detail: bad.join('\n') || 'none' });
|
|
260
|
+
}
|
|
261
|
+
if (JSON_OUT) console.log(JSON.stringify(results.map(({ run, ...r }) => r), null, 1));
|
|
262
|
+
else {
|
|
263
|
+
for (const r of results) {
|
|
264
|
+
console.log(`${r.ok ? 'PASS' : 'FAIL'} ${r.id.padEnd(4)} [${r.area}] ${r.title}`);
|
|
265
|
+
if (!r.ok) for (const l of String(r.detail).split('\n')) console.log(` ${l}`);
|
|
266
|
+
}
|
|
267
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
268
|
+
console.log(`\n${results.length - failed}/${results.length} pass${MACHINE ? ' (repo + machine)' : ' (repo)'}`);
|
|
269
|
+
}
|
|
270
|
+
process.exitCode = results.every((r) => r.ok) ? 0 : 1;
|
|
@@ -14,10 +14,14 @@ export const API_BILLING_ENV = Object.freeze([
|
|
|
14
14
|
'GEMINI_API_KEY',
|
|
15
15
|
'XAI_API_KEY',
|
|
16
16
|
]);
|
|
17
|
+
export const PRIVATE_SIGNING_ENV = Object.freeze([
|
|
18
|
+
'RUVNET_SIGNING_KEY', 'RUVNET_FABLE_REVIEW_SIGNING_KEY', 'RUVNET_ASTRA_REVIEW_SIGNING_KEY',
|
|
19
|
+
]);
|
|
17
20
|
|
|
18
21
|
export function subscriptionOnlyEnv(parent = process.env) {
|
|
19
22
|
const child = { ...parent, RUVNET_SUBSCRIPTION_ONLY: '1' };
|
|
20
23
|
for (const name of API_BILLING_ENV) delete child[name];
|
|
24
|
+
for (const name of PRIVATE_SIGNING_ENV) delete child[name];
|
|
21
25
|
return child;
|
|
22
26
|
}
|
|
23
27
|
|
package/scripts/sync-version.mjs
CHANGED
|
@@ -41,6 +41,14 @@ export function writeExplainerBadgeVersion(s, version) {
|
|
|
41
41
|
return s.replace(/(·\s*v)\d+\.\d+\.\d+(?:-[\w.-]+)?/, `$1${version}`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// The explainer once showed a second, competing version next to the product version
|
|
45
|
+
// ("candidate preview" ... "public npm vX.Y.Z") — two versions on one public page, exactly the
|
|
46
|
+
// defect single-source-check.mjs check B10 exists to catch (fixed 2026-09-26). This regression
|
|
47
|
+
// guard keeps it from silently coming back.
|
|
48
|
+
export function hasSplitVersionLabel(s) {
|
|
49
|
+
return /candidate preview|public npm v\d/i.test(s);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
// Each target: a file, a regex to find the version-bearing line, and the corrected line.
|
|
45
53
|
const targets = [
|
|
46
54
|
{ // Codex plugin manifest — same product, same release train as the Claude manifest
|
|
@@ -160,6 +168,20 @@ if (CHECK && fs.existsSync(path.join(ROOT, 'kb/RVF-GENERATIONS.json'))) {
|
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
// Guard: the explainer must never again show a second, competing version next to the product
|
|
172
|
+
// version (the "candidate preview" / "public npm vX.Y.Z" split fixed 2026-09-26 — see
|
|
173
|
+
// hasSplitVersionLabel() above and single-source-check.mjs check B10). This is a content
|
|
174
|
+
// regression, not a mechanical one, so --check only reports it; there is no auto-fix.
|
|
175
|
+
{
|
|
176
|
+
const p = path.join(ROOT, 'explainer/index.html');
|
|
177
|
+
if (fs.existsSync(p) && hasSplitVersionLabel(fs.readFileSync(p, 'utf8'))) {
|
|
178
|
+
if (CHECK) {
|
|
179
|
+
console.error('[version] DRIFT: explainer/index.html shows a second/candidate version label ("candidate preview" / "public npm vX") — the page must show exactly one current version');
|
|
180
|
+
drift++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
// Guard: no stray hardcoded product-version literal anywhere in the source tree — every code path must
|
|
164
186
|
// read getVersion() (or inherit a synced literal via the `targets` above / the README badge). Rather than
|
|
165
187
|
// a fixed 3-file allowlist (which silently missed strays in every other file), walk the WHOLE tree and
|
package/scripts/trismart.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { TRI_HOST_MODEL_IDS } from './review-model-defaults.mjs';
|
|
6
7
|
|
|
7
8
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const DUAL = path.join(ROOT, 'dual-host-deliberation.mjs');
|
|
@@ -24,9 +25,8 @@ if (!task) { usage(); process.exit(2); }
|
|
|
24
25
|
|
|
25
26
|
if (dryRun) {
|
|
26
27
|
const providers = mode === 'dual' ? ['claude', 'codex'] : ['claude', 'codex', 'grok'];
|
|
27
|
-
console.log(JSON.stringify({ status: 'ready', mode, providers, models:
|
|
28
|
-
|
|
29
|
-
}, billingPath: 'native subscription/OAuth CLI; API-key variables unset', taskHash: task.length }, null, 2));
|
|
28
|
+
console.log(JSON.stringify({ status: 'ready', mode, providers, models: TRI_HOST_MODEL_IDS,
|
|
29
|
+
billingPath: 'native subscription/OAuth CLI; API-key variables unset', taskHash: task.length }, null, 2));
|
|
30
30
|
process.exit(0);
|
|
31
31
|
}
|
|
32
32
|
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -78,7 +78,6 @@ const STANDALONE = [
|
|
|
78
78
|
['dream-issue-gate', 'pure Dream Cycle disposition policy; invoked by the external issue adapter, never a GitHub writer'],
|
|
79
79
|
['sync-census', 'explicit maintainer census writer; a destructive source-to-surface refresh is never scheduled'],
|
|
80
80
|
['sync-commands', 'explicit maintainer alias synchronizer; run deliberately before release, never from a lifecycle hook'],
|
|
81
|
-
['version-bump-gate', 'retired automatic interceptor; explicit version checks own release validation'],
|
|
82
81
|
['project-progression-checkpoint', 'the body of the shipped `/ruvnet-brain:checkpoint` command '
|
|
83
82
|
+ '(plugin/commands/checkpoint.md:39; added a7167b6b 2026-09-11). The command host executes that '
|
|
84
83
|
+ 'instruction, and this check does not scan command markdown — the same shape as '
|
|
@@ -89,6 +88,10 @@ const STANDALONE = [
|
|
|
89
88
|
+ '.claude/worktrees/ — their package.json copies had been counted as callers all day.'],
|
|
90
89
|
['lesson-seed', 'one-shot seeding, run deliberately by a human'],
|
|
91
90
|
['lesson-ratify', 'the human control surface — a CLI is its entire purpose'],
|
|
91
|
+
['lesson-migrate-agentdb', 'one-time reconciliation between the two AgentDB lesson stores and the '
|
|
92
|
+
+ 'one plugin lesson store, run deliberately by a human/model, never scheduled. Completion is '
|
|
93
|
+
+ 'proven and kept honest by tests/unit/lesson-migrate-agentdb.test.mjs (0 pending rows), a drift '
|
|
94
|
+
+ 'canary rather than automated invocation — the same shape as lesson-seed/lesson-ratify above.'],
|
|
92
95
|
['stamp-sweep', 'ADR-056 §2 — the ONE-TIME backfill half of the stamp rule. A human runs it once '
|
|
93
96
|
+ '(--apply) to reach the files nobody is editing; the ongoing half is the md-stamp PostToolUse '
|
|
94
97
|
+ 'hook, which IS wired. Deliberately not in a gate: it WRITES to documents, and a writer that '
|
|
@@ -109,9 +112,6 @@ const STANDALONE = [
|
|
|
109
112
|
['fix-metaharness-memretrieve', 'one-shot historical repair; kept for the record'],
|
|
110
113
|
['gen-console-images', 'build-time asset generation, run by hand'],
|
|
111
114
|
['adr-backfill', 'one-shot backfill by a human; its result is enforced by adr-format.test.mjs'],
|
|
112
|
-
['stamp-existing-rvf-generations', 'one-shot maintainer migration that binds already-built canonical '
|
|
113
|
-
+ 'RVFs to RVF-GENERATIONS.json and optionally prunes legacy sidecars; build-bundle.mjs consumes '
|
|
114
|
-
+ 'and validates the resulting manifest, so scheduling this destructive migration would be wrong'],
|
|
115
115
|
['release', 'the ship path, run by a human'],
|
|
116
116
|
['execution-preflight', 'external orchestration boundary — invoked by the host before consequential Ruflo/Codex execution; no in-repo caller exists because the host supplies the live Brain and AgentDB receipts'],
|
|
117
117
|
['fix-workstream', 'session-supervised coordination CLI run explicitly by the integration owner or an '
|
|
@@ -230,6 +230,27 @@ const STANDALONE = [
|
|
|
230
230
|
+ 'ground-before-write. Per ADR-0014 ownership moved to the Kling skill (confirmed live: a copy '
|
|
231
231
|
+ 'ships at ~/.claude/skills/klingai/scripts/kling-preflight.sh); NOT currently wired into any '
|
|
232
232
|
+ 'settings.json there either — honestly dormant until a user opts in, not a silent gap'],
|
|
233
|
+
|
|
234
|
+
// H5 (2026-09-26 dead-code audit): decision-gate.mjs's REGISTRY['bash'] was the only production
|
|
235
|
+
// caller of these three — never registered in plugin/hooks/hooks.json or codex-hooks.json
|
|
236
|
+
// (continuity-hook-policy.mjs's own header: "remains reachable through hook-shim's dispatch table
|
|
237
|
+
// by explicit invocation" only). Removing that dead route makes each of these genuinely uncalled
|
|
238
|
+
// by any product path. Not deleted: each exports pure, independently-tested logic with its own
|
|
239
|
+
// passing suite — identifier-preflight.test.mjs / spend-guard.test.mjs import and exercise these
|
|
240
|
+
// functions directly, and tests/unit/lesson-gate.test.mjs imports degradation-watch.mjs's
|
|
241
|
+
// `dependentEvent` to cross-check lesson-hooks.sh's own pattern. A test importing an export is not
|
|
242
|
+
// a product caller (same distinction this file already draws for correction-detect-measure above),
|
|
243
|
+
// so these are honestly STANDALONE rather than silently left to read as still-wired.
|
|
244
|
+
['identifier-preflight', 'H5: orphaned by decision-gate.mjs\'s dead bash route (REGISTRY[\'bash\'] '
|
|
245
|
+
+ 'removed 2026-09-26). Pure `check`/`identifierIn` logic remains directly imported and exercised '
|
|
246
|
+
+ 'by its own tests/unit/identifier-preflight.test.mjs; no product path calls it'],
|
|
247
|
+
['spend-guard', 'H5: orphaned by decision-gate.mjs\'s dead bash route (REGISTRY[\'bash\'] removed '
|
|
248
|
+
+ '2026-09-26). Its logic remains directly imported and exercised by its own '
|
|
249
|
+
+ 'tests/unit/spend-guard.test.mjs; no product path calls it'],
|
|
250
|
+
['degradation-watch', 'H5: orphaned by decision-gate.mjs\'s dead bash route (REGISTRY[\'bash\'] '
|
|
251
|
+
+ 'removed 2026-09-26). Its `dependentEvent` export remains directly imported by '
|
|
252
|
+
+ 'tests/unit/lesson-gate.test.mjs (cross-checked against lesson-hooks.sh\'s own pattern) and by '
|
|
253
|
+
+ 'its own test file; no product path calls it'],
|
|
233
254
|
];
|
|
234
255
|
// REMOVED 2026-07-22, each verified before removal:
|
|
235
256
|
// check-legibility / check-indexation / status-honesty — claimed "invoked from the workflow";
|
|
@@ -889,9 +910,24 @@ export function hookWiringAudit({
|
|
|
889
910
|
const scriptsDir = path.join(repo, 'plugin/scripts');
|
|
890
911
|
let all = [];
|
|
891
912
|
try { all = fs.readdirSync(scriptsDir).filter((f) => /\.(mjs|sh)$/.test(f)); } catch { /* no dir */ }
|
|
913
|
+
// H6 (2026-09-26 dead-code audit): hook-shim.mjs is a DISPATCH TABLE, not a script that
|
|
914
|
+
// unconditionally executes everything it mentions. `scanConfig()` above already proves reachability
|
|
915
|
+
// for a TABLE entry CORRECTLY — only when its id is genuinely dispatched by a real command string in
|
|
916
|
+
// a manifest. Letting hook-shim.mjs ALSO participate as a "from" in the generic fixed-point pass
|
|
917
|
+
// below double-counts every OTHER entry in its TABLE as "spawned", because the pass's own predicate
|
|
918
|
+
// (callerPattern) matches any quoted filename — and every TABLE entry is, by construction, a quoted
|
|
919
|
+
// filename in hook-shim.mjs's source, dispatched or not. Measured: before this exclusion,
|
|
920
|
+
// design-wall.sh, route-dispatch.sh, verify-interface.sh, learn-capture.sh, learn-flush.mjs,
|
|
921
|
+
// md-stamp.mjs, signal-watch.mjs, routing-outcome-capture.mjs and swarm-slot-recycler.mjs all read
|
|
922
|
+
// "wired · via spawned by plugin/scripts/hook-shim.mjs" while zero manifest (hooks.json,
|
|
923
|
+
// codex-hooks.json, this repo's or the user's settings.json) ever dispatches most of their ids —
|
|
924
|
+
// exactly the false positive this check exists to catch. Excluding it here does not remove any
|
|
925
|
+
// GENUINE reachability: every id hook-shim.mjs's TABLE actually dispatches was already added by
|
|
926
|
+
// scanConfig() above, before this loop runs at all.
|
|
892
927
|
for (let i = 0; i < 10; i++) {
|
|
893
928
|
let changed = false;
|
|
894
929
|
for (const from of [...reached.keys()]) {
|
|
930
|
+
if (from === 'hook-shim.mjs') continue;
|
|
895
931
|
const src = sourceCache.commentFree(path.join(scriptsDir, from));
|
|
896
932
|
if (src === null) continue;
|
|
897
933
|
for (const cand of all) {
|
|
Binary file
|
|
@@ -1,124 +0,0 @@
|
|
|
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); local Git checks and a Node maintenance-state check.
|
|
22
|
-
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
set -uo pipefail
|
|
25
|
-
|
|
26
|
-
# Reversible, repository-scoped development maintenance; no profile, ledger, or Git work first.
|
|
27
|
-
MAINTENANCE_HELPER="${BASH_SOURCE[0]%/*}/development-maintenance.mjs"
|
|
28
|
-
if [ -f "$MAINTENANCE_HELPER" ] && node --input-type=module -e '
|
|
29
|
-
import { pathToFileURL } from "node:url";
|
|
30
|
-
const { developmentHooksSuspended } = await import(pathToFileURL(process.argv[1]));
|
|
31
|
-
process.exit(developmentHooksSuspended() ? 0 : 1);
|
|
32
|
-
' "$MAINTENANCE_HELPER" >/dev/null 2>&1; then
|
|
33
|
-
exit 0
|
|
34
|
-
fi
|
|
35
|
-
|
|
36
|
-
INPUT=""
|
|
37
|
-
# BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
|
|
38
|
-
# opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
|
|
39
|
-
# harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
|
|
40
|
-
# exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
|
|
41
|
-
# is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
|
|
42
|
-
# the whole thing back at once and a per-iteration cap never fires.
|
|
43
|
-
while IFS= read -r -t 2 _l; do
|
|
44
|
-
INPUT+="$_l"
|
|
45
|
-
[ ${#INPUT} -ge 65536 ] && break
|
|
46
|
-
done
|
|
47
|
-
[ -n "$_l" ] && INPUT+="$_l"
|
|
48
|
-
INPUT="${INPUT:0:65536}"
|
|
49
|
-
[ -n "$INPUT" ] || exit 0
|
|
50
|
-
|
|
51
|
-
PROFILE="${MODEL_ROUTER_PROFILE:-$HOME/.claude/model-router/profile.json}"
|
|
52
|
-
[ -f "$PROFILE" ] || exit 0
|
|
53
|
-
[ "${RUVNET_SKIP_VERSION_GATE:-0}" = "1" ] && exit 0
|
|
54
|
-
|
|
55
|
-
# Consume JSON escape pairs so quoted commit messages do not truncate the command before a
|
|
56
|
-
# following git push. This retains the existing bounded Bash parser's matching behavior.
|
|
57
|
-
field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
|
|
58
|
-
[ "$(field tool_name)" = "Bash" ] || exit 0
|
|
59
|
-
CMD=$(field command)
|
|
60
|
-
[[ $CMD == *"git push"* ]] || exit 0
|
|
61
|
-
|
|
62
|
-
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
63
|
-
|
|
64
|
-
# The version source of truth, in precedence order. A repo with neither is not version-managed
|
|
65
|
-
# by this convention — pass untouched.
|
|
66
|
-
SRC=""
|
|
67
|
-
for c in plugin/.claude-plugin/plugin.json package.json; do
|
|
68
|
-
[ -f "$ROOT/$c" ] && { SRC="$c"; break; }
|
|
69
|
-
done
|
|
70
|
-
[ -n "$SRC" ] || exit 0
|
|
71
|
-
|
|
72
|
-
UP=$(git -C "$ROOT" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null) || UP=origin/main
|
|
73
|
-
git -C "$ROOT" rev-parse --verify "$UP" >/dev/null 2>&1 || exit 0
|
|
74
|
-
|
|
75
|
-
AHEAD=$(git -C "$ROOT" rev-list --count "$UP..HEAD" 2>/dev/null) || exit 0
|
|
76
|
-
[ "$AHEAD" -gt 0 ] || exit 0 # nothing outgoing → nothing to gate
|
|
77
|
-
|
|
78
|
-
ver_at() { # ver_at <rev> — version string in $SRC at that rev, empty on any failure
|
|
79
|
-
local j re='"version"[[:space:]]*:[[:space:]]*"([^"]*)"'
|
|
80
|
-
j=$(git -C "$ROOT" show "$1:$SRC" 2>/dev/null) || return 0
|
|
81
|
-
[[ $j =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"
|
|
82
|
-
}
|
|
83
|
-
V_HEAD=$(ver_at HEAD); V_UP=$(ver_at "$UP")
|
|
84
|
-
[ -n "$V_HEAD" ] && [ -n "$V_UP" ] || exit 0 # can't read either side → fail open
|
|
85
|
-
[ "$V_HEAD" != "$V_UP" ] && exit 0 # bumped → pass
|
|
86
|
-
|
|
87
|
-
# ── LESSON STORE CONSULTATION (ADR-030 L3, wired 2026-07-22) ────────────────────────────────────
|
|
88
|
-
# This gate hardcoded its own message for months while lesson L05 — the identical rule, recorded 52
|
|
89
|
-
# times across 4 projects — sat in a store that nothing read. A grep for `lessonsFor` across every
|
|
90
|
-
# gate returned zero: lessons were mined, weighted, trust-boundaried, and consumed by nobody.
|
|
91
|
-
#
|
|
92
|
-
# Now the store speaks here. Today every lesson is an unratified candidate, so this ADDS the lesson's
|
|
93
|
-
# own words and evidence to the refusal below rather than changing whether it fires. The day the
|
|
94
|
-
# owner ratifies L05, the same wire starts refusing on the lesson's authority instead of on this
|
|
95
|
-
# script's hardcoded copy — with no change to this file. Enforcement is data, not a rewrite.
|
|
96
|
-
#
|
|
97
|
-
# Fails open by construction: any error, missing store, or absent node exits this block silently. A
|
|
98
|
-
# gate that blocks a push because it could not read a config file is a gate people switch off.
|
|
99
|
-
LESSON_GATE="$(cd "$(dirname "$0")/../.." 2>/dev/null && pwd)/scripts/lesson-gate.mjs"
|
|
100
|
-
if [ -f "$LESSON_GATE" ] && command -v node >/dev/null 2>&1; then
|
|
101
|
-
LESSON_OUT="$(node "$LESSON_GATE" --trigger ship 2>/dev/null || true)"
|
|
102
|
-
fi
|
|
103
|
-
|
|
104
|
-
read -r -d '' MSG <<EOF || true
|
|
105
|
-
⛔ BLOCKED — this push carries $AHEAD commit(s) but NO version increment ($SRC still $V_UP).
|
|
106
|
-
|
|
107
|
-
THE VERSION NUMBER IS THE UPDATE SIGNAL. Plugin caches, update checks, and other sessions decide
|
|
108
|
-
whether to pull fresh code by comparing versions — a push without a bump is an update nothing can
|
|
109
|
-
see. This exact miss served a restarted session stale 2.5.2 without /savings on 2026-07-13.
|
|
110
|
-
|
|
111
|
-
Before pushing:
|
|
112
|
-
1. bump "version" in $SRC (major.minor.bugfix)
|
|
113
|
-
2. node scripts/sync-version.mjs # syncs every surface, --check must pass
|
|
114
|
-
3. include the bump in the outgoing commits, then push
|
|
115
|
-
|
|
116
|
-
(Stuart, 2026-07-13: "that's not negotiable." Deliberate override, say why out loud:
|
|
117
|
-
RUVNET_SKIP_VERSION_GATE=1)
|
|
118
|
-
${LESSON_OUT:+
|
|
119
|
-
── from your own lesson store ─────────────────────────────────────────────
|
|
120
|
-
$LESSON_OUT}
|
|
121
|
-
EOF
|
|
122
|
-
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
|
|
123
|
-
printf '%s\n' "$MSG" >&2
|
|
124
|
-
exit 2
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
6
|
-
const dir = path.resolve(ROOT, process.env.QE_RECEIPT_DIR || '.qe/receipts');
|
|
7
|
-
const required = ['preflight', 'check', 'integration', 'release', 'resources', 'windows-unit'];
|
|
8
|
-
const failures = [];
|
|
9
|
-
const receipts = required.map((lane) => {
|
|
10
|
-
const file = path.join(dir, `${lane}.json`);
|
|
11
|
-
if (!fs.existsSync(file)) { failures.push(`${lane}: receipt missing`); return null; }
|
|
12
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) {
|
|
13
|
-
failures.push(`${lane}: invalid receipt (${error.message})`); return null;
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
const shas = new Set(receipts.filter(Boolean).map((receipt) => receipt.sha));
|
|
17
|
-
if (shas.size !== 1) failures.push(`receipts are not bound to one SHA: ${[...shas].join(', ') || '<none>'}`);
|
|
18
|
-
const runIds = new Set(receipts.filter(Boolean).map((receipt) => receipt.runId));
|
|
19
|
-
if (runIds.size !== 1) failures.push(`receipts are not bound to one run: ${[...runIds].join(', ') || '<none>'}`);
|
|
20
|
-
for (const [index, receipt] of receipts.entries()) {
|
|
21
|
-
const lane = required[index];
|
|
22
|
-
if (!receipt) continue;
|
|
23
|
-
if (receipt.schema !== 'ruvnet-brain.agentic-qe.receipt' || receipt.contract !== 'agentic-qe-4.3') {
|
|
24
|
-
failures.push(`${lane}: wrong receipt contract`);
|
|
25
|
-
}
|
|
26
|
-
if (receipt.lane !== lane || receipt.status !== 'PASS') failures.push(`${lane}: status ${receipt.status || '<missing>'}`);
|
|
27
|
-
for (const step of receipt.steps || []) {
|
|
28
|
-
if (step.status !== 'PASS') failures.push(`${lane} step ${step.index}: ${step.status}`);
|
|
29
|
-
if (step.tests && (step.tests.total === 0 || step.tests.skipped > 0)) {
|
|
30
|
-
failures.push(`${lane} step ${step.index}: skipped or empty test result`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const result = {
|
|
35
|
-
schema: 'ruvnet-brain.agentic-qe.aggregate', contract: 'agentic-qe-4.3',
|
|
36
|
-
sha: shas.size === 1 ? [...shas][0] : null, required,
|
|
37
|
-
status: failures.length ? 'FAIL' : 'PASS', failures, receipts,
|
|
38
|
-
};
|
|
39
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
-
fs.writeFileSync(path.join(dir, 'aggregate.json'), `${JSON.stringify(result, null, 2)}\n`);
|
|
41
|
-
console.log(JSON.stringify({ status: result.status, sha: result.sha, failures }));
|
|
42
|
-
process.exitCode = failures.length ? 1 : 0;
|