ruvnet-brain 4.0.1 → 4.0.2
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 +1 -0
- package/README.md +4 -4
- package/bin/install.mjs +100 -5
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +8 -22
- package/plugin/.claude-plugin/marketplace.json +1 -0
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +2 -2
- package/plugin/commands/configure.md +3 -2
- package/plugin/commands/rvbc.md +4 -3
- package/plugin/commands/rvcb.md +2 -2
- package/plugin/hooks/hooks.json +1 -2
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +21 -0
- package/plugin/scripts/detach.mjs +14 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -6
- package/plugin/scripts/hook-shim.mjs +7 -7
- package/plugin/scripts/learn-capture.sh +22 -3
- package/plugin/scripts/learn-flush.mjs +21 -4
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +3 -858
- package/plugin/skills/brain-console/SKILL.md +4 -2
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
- package/plugin/skills/rvbc/SKILL.md +9 -6
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
- package/plugin/scripts/finalize-token-meter.mjs +0 -25
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-indexation.mjs — POSITIVE index-status confirmation for RuvNet-Brain's SEO surfaces.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: "indexable" is a passive assumption; "indexed" is a live fact. This probe
|
|
6
|
+
* replaces the assumption with a check you can run any time. It NEVER infers "indexed" from
|
|
7
|
+
* HTTP health alone — serving-ready is necessary but NOT sufficient for indexed.
|
|
8
|
+
*
|
|
9
|
+
* For each target it reports three independent facts:
|
|
10
|
+
* 1. SERVING — does the URL return 200 (or a clean redirect), with a self-canonical and no noindex?
|
|
11
|
+
* 2. BING — does a live `site:` query on Bing return the URL? (scrape; may be challenged)
|
|
12
|
+
* 3. GOOGLE — best-effort `site:` on Google. Datacenter IPs are often challenged → "inconclusive".
|
|
13
|
+
* AUTHORITATIVE Google status needs Search Console; see GSC note below.
|
|
14
|
+
*
|
|
15
|
+
* Ground-truth upgrade path: set GSC_ACCESS_TOKEN (a Search Console OAuth token with the
|
|
16
|
+
* webmasters.readonly scope) and this probe will call the URL Inspection API for a definitive
|
|
17
|
+
* Google verdict instead of scraping. Until then Google is best-effort and labeled as such.
|
|
18
|
+
*
|
|
19
|
+
* Usage: node scripts/check-indexation.mjs
|
|
20
|
+
* Exit: 0 always (it's a report, not a gate). Parse the printed table.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TARGETS = [
|
|
24
|
+
'https://isovision.ai/ruvnet-brain/',
|
|
25
|
+
'https://github.com/stuinfla/ruvnet-brain',
|
|
26
|
+
'https://isovision.ai/ruvnet-brain/',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const UA =
|
|
30
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
31
|
+
'(KHTML, like Gecko) Chrome/126.0 Safari/537.36';
|
|
32
|
+
|
|
33
|
+
const CHALLENGE = /captcha|unusual traffic|verify you are|challenge|are you a robot/i;
|
|
34
|
+
|
|
35
|
+
async function serving(url) {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(url, { redirect: 'manual', headers: { 'User-Agent': UA } });
|
|
38
|
+
const loc = res.headers.get('location');
|
|
39
|
+
if (res.status >= 300 && res.status < 400 && loc) return { ok: true, note: `${res.status}→ ${loc}` };
|
|
40
|
+
if (res.status !== 200) return { ok: false, note: `HTTP ${res.status}` };
|
|
41
|
+
const html = await res.text();
|
|
42
|
+
const canonical = (html.match(/<link[^>]+rel=["']canonical["'][^>]*>/i) || [''])[0];
|
|
43
|
+
const noindex = /content=["'][^"']*noindex/i.test(html) || res.headers.get('x-robots-tag')?.includes('noindex');
|
|
44
|
+
const selfCanon = canonical.includes(url.replace(/\/$/, '')) || canonical.includes(url);
|
|
45
|
+
return { ok: !noindex, note: `200${noindex ? ' ⚠NOINDEX' : ''}${selfCanon ? ' canon=self' : canonical ? ' canon=other' : ' no-canon'}` };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, note: `unreachable: ${e.message}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function siteQuery(engine, url) {
|
|
52
|
+
const host = new URL(url).host;
|
|
53
|
+
const path = new URL(url).pathname.replace(/\/$/, '');
|
|
54
|
+
const q = encodeURIComponent(`site:${host}${path}`);
|
|
55
|
+
const endpoint =
|
|
56
|
+
engine === 'bing' ? `https://www.bing.com/search?q=${q}` : `https://www.google.com/search?q=${q}`;
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(endpoint, { headers: { 'User-Agent': UA } });
|
|
59
|
+
const body = await res.text();
|
|
60
|
+
if (CHALLENGE.test(body) || res.status !== 200) return 'inconclusive (bot-challenged)';
|
|
61
|
+
// Heuristic: an indexed result links back to the host+path somewhere in the results markup.
|
|
62
|
+
const needle = `${host}${path}`.toLowerCase();
|
|
63
|
+
const hits = (body.toLowerCase().match(new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
|
64
|
+
const noResults = /did not match any documents|no results found|there are no results/i.test(body);
|
|
65
|
+
if (noResults) return 'NOT indexed';
|
|
66
|
+
return hits > 1 ? 'INDEXED' : 'NOT indexed';
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return `inconclusive (${e.message})`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function gscInspect(url) {
|
|
73
|
+
const token = process.env.GSC_ACCESS_TOKEN;
|
|
74
|
+
if (!token) return null;
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ inspectionUrl: url, siteUrl: `sc-domain:${new URL(url).host}` }),
|
|
80
|
+
});
|
|
81
|
+
const j = await res.json();
|
|
82
|
+
const v = j?.inspectionResult?.indexStatusResult?.verdict;
|
|
83
|
+
return v ? `GSC(authoritative): ${v}` : `GSC: ${JSON.stringify(j).slice(0, 120)}`;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return `GSC error: ${e.message}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
(async () => {
|
|
90
|
+
const stamp = new Date().toISOString();
|
|
91
|
+
console.log(`\n RuvNet-Brain indexation probe — ${stamp}`);
|
|
92
|
+
console.log(' (SERVING = necessary; INDEXED = the fact that matters)\n');
|
|
93
|
+
for (const url of TARGETS) {
|
|
94
|
+
const [srv, bing, google, gsc] = await Promise.all([
|
|
95
|
+
serving(url),
|
|
96
|
+
siteQuery('bing', url),
|
|
97
|
+
siteQuery('google', url),
|
|
98
|
+
gscInspect(url),
|
|
99
|
+
]);
|
|
100
|
+
console.log(` ${url}`);
|
|
101
|
+
console.log(` SERVING : ${srv.ok ? '✓' : '✗'} ${srv.note}`);
|
|
102
|
+
console.log(` BING : ${bing}`);
|
|
103
|
+
console.log(` GOOGLE : ${gsc || google + ' (best-effort; set GSC_ACCESS_TOKEN for authoritative)'}`);
|
|
104
|
+
console.log('');
|
|
105
|
+
}
|
|
106
|
+
console.log(' Reminder: the fastest way to a definitive Google YES is Search Console →');
|
|
107
|
+
console.log(' URL Inspection → Request Indexing (needs a Google login). Everything else is latency.\n');
|
|
108
|
+
})();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-legibility.mjs — the gate for the bug that kept shipping.
|
|
3
|
+
//
|
|
4
|
+
// WHY (2026-07-17). Twice in one session a diagram shipped whose labels were PRESENT, unclipped,
|
|
5
|
+
// non-overflowing, xmllint-clean, zero failed requests — and unreadable. The tips hero rendered its
|
|
6
|
+
// labels at 8.1px; the MetaHarness diagram rendered its at 2.9px on a phone. Stuart's words: "I'm
|
|
7
|
+
// not sure what that first animation is." He couldn't read it. Every automated check we owned
|
|
8
|
+
// passed, because none of them measured the one thing that mattered.
|
|
9
|
+
//
|
|
10
|
+
// The cause is always the same: an SVG declares font-size in viewBox units, then gets rendered into
|
|
11
|
+
// a column narrower than its viewBox. Every label silently multiplies by (renderedWidth/viewBoxWidth).
|
|
12
|
+
// A 640-unit viewBox in a 453px column downscales 11.5px type to 8.1px. Nothing errors. It just
|
|
13
|
+
// becomes invisible.
|
|
14
|
+
//
|
|
15
|
+
// This gate exists because of a hard-won distinction (see memory: feedback_never_grade_your_own_work):
|
|
16
|
+
// a wall can only enforce FACTS, never TASTE. "Is this beautiful" is not gateable and any gate that
|
|
17
|
+
// pretends otherwise just launders an opinion into a receipt. "Is this text >= N effective pixels"
|
|
18
|
+
// IS a fact. So we gate exactly that, and nothing more.
|
|
19
|
+
//
|
|
20
|
+
// It measures via getScreenCTM() — the browser's REAL accumulated transform — not a hand-computed
|
|
21
|
+
// ratio, so it survives nested transforms, CSS scaling, and responsive containers.
|
|
22
|
+
//
|
|
23
|
+
// It measures IN THE REAL PAGE, never the asset in isolation. That distinction is load-bearing: the
|
|
24
|
+
// MetaHarness agent tested the asset at 380px and passed, while the real page squeezed it to 261px
|
|
25
|
+
// behind gutters and padding. Testing the asset is not testing the page.
|
|
26
|
+
//
|
|
27
|
+
// Usage:
|
|
28
|
+
// node scripts/check-legibility.mjs --url http://127.0.0.1:7411/tips.html
|
|
29
|
+
// node scripts/check-legibility.mjs --url <url> --min 12 --widths 1440,1024,768 --selector '.hero-scene'
|
|
30
|
+
//
|
|
31
|
+
// Exit 0 = every label clears --min at every width. Exit 3 = something is too small to read.
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const get = (k, d = null) => { const i = args.indexOf('--' + k); return i >= 0 ? args[i + 1] : d; };
|
|
36
|
+
|
|
37
|
+
const url = get('url');
|
|
38
|
+
const MIN = Number(get('min', 12));
|
|
39
|
+
const widths = String(get('widths', '1440,1024,768')).split(',').map(Number);
|
|
40
|
+
const selector = get('selector', 'svg');
|
|
41
|
+
const themes = String(get('themes', 'dark,light')).split(',');
|
|
42
|
+
|
|
43
|
+
if (!url) {
|
|
44
|
+
console.error('usage: check-legibility.mjs --url <url> [--min 12] [--widths 1440,1024,768] [--selector svg]');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Playwright lives in the global npm prefix; resolve it rather than assuming a local install.
|
|
49
|
+
let chromium;
|
|
50
|
+
try {
|
|
51
|
+
const require = createRequire(import.meta.url);
|
|
52
|
+
({ chromium } = require('playwright'));
|
|
53
|
+
} catch {
|
|
54
|
+
try {
|
|
55
|
+
const require = createRequire(import.meta.url);
|
|
56
|
+
({ chromium } = require('/Users/stuartkerr/.npm-global/lib/node_modules/playwright/index.js'));
|
|
57
|
+
} catch {
|
|
58
|
+
console.error('✗ playwright not resolvable — install it or run this gate where it is available');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const browser = await chromium.launch();
|
|
64
|
+
const failures = [];
|
|
65
|
+
const rows = [];
|
|
66
|
+
|
|
67
|
+
for (const width of widths) {
|
|
68
|
+
for (const theme of themes) {
|
|
69
|
+
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: theme });
|
|
70
|
+
// NOT networkidle: the console holds a live connection open and would time out forever.
|
|
71
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
72
|
+
// The page keys off data-theme, not just the OS hint — stamp both or the "dark" run is a lie.
|
|
73
|
+
await page.evaluate((t) => { document.documentElement.dataset.theme = t; }, theme);
|
|
74
|
+
await page.waitForTimeout(900);
|
|
75
|
+
|
|
76
|
+
// Assert the viewport is what we asked for. Playwright's option is `viewport`; the plausible-
|
|
77
|
+
// looking `viewportSize` is SILENTLY IGNORED and renders everything at 1280. That already
|
|
78
|
+
// produced one false report on this project.
|
|
79
|
+
const vp = page.viewportSize();
|
|
80
|
+
if (vp.width !== width) {
|
|
81
|
+
failures.push(`viewport lied: asked ${width}, got ${vp.width}`);
|
|
82
|
+
await page.close();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// (1) INLINE svg — the DOM can see its text, so use the browser's real transform.
|
|
87
|
+
const found = await page.evaluate(({ sel, min }) => {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const svg of document.querySelectorAll(sel)) {
|
|
90
|
+
if (!svg.getScreenCTM) continue;
|
|
91
|
+
const ctm = svg.getScreenCTM();
|
|
92
|
+
if (!ctm) continue; // not rendered (display:none, detached)
|
|
93
|
+
const box = svg.getBoundingClientRect();
|
|
94
|
+
if (box.width === 0) continue;
|
|
95
|
+
for (const t of svg.querySelectorAll('text')) {
|
|
96
|
+
const txt = (t.textContent || '').trim();
|
|
97
|
+
if (!txt) continue;
|
|
98
|
+
const cs = getComputedStyle(t);
|
|
99
|
+
// Hidden text is not a legibility problem — skip display:none / visibility:hidden /
|
|
100
|
+
// opacity:0, and anything a parent hid. A label a responsive breakpoint deliberately
|
|
101
|
+
// hides (e.g. the hero rings' labels on phones, where a legend carries them instead)
|
|
102
|
+
// must not be flagged as "3px and unreadable" when it renders zero pixels.
|
|
103
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue;
|
|
104
|
+
let box; try { box = t.getBoundingClientRect(); } catch { box = null; }
|
|
105
|
+
if (box && (box.width === 0 || box.height === 0)) continue; // clipped/collapsed → not visible
|
|
106
|
+
const declared = parseFloat(cs.fontSize);
|
|
107
|
+
if (!declared) continue;
|
|
108
|
+
// ctm.a is the real horizontal scale the browser applies, transforms included.
|
|
109
|
+
const effective = +(declared * ctm.a).toFixed(1);
|
|
110
|
+
out.push({ text: txt.slice(0, 34), declared, effective, ok: effective >= min,
|
|
111
|
+
svgW: Math.round(box.width) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}, { sel: selector, min: MIN });
|
|
116
|
+
|
|
117
|
+
// (2) <img src="*.svg"> — the whole reason this gate nearly shipped useless. An SVG behind an
|
|
118
|
+
// <img> is a CLOSED document: the parent page cannot see inside it, so pass (1) reports zero
|
|
119
|
+
// labels and the file sails through. That is exactly how metaharness.svg rendered its labels at
|
|
120
|
+
// 2.9px on a phone while every check "passed". So: find the <img>, fetch the SVG's SOURCE,
|
|
121
|
+
// read its viewBox + declared font-sizes, and scale by the img's REAL rendered width.
|
|
122
|
+
const imgSvgs = await page.evaluate(() => {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const img of document.querySelectorAll('img[src$=".svg"], object[data$=".svg"]')) {
|
|
125
|
+
const box = img.getBoundingClientRect();
|
|
126
|
+
if (box.width === 0) continue; // not rendered
|
|
127
|
+
out.push({ src: img.getAttribute('src') || img.getAttribute('data'), renderedW: box.width });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const { src, renderedW } of imgSvgs) {
|
|
133
|
+
const abs = new URL(src, url).href;
|
|
134
|
+
let text;
|
|
135
|
+
try {
|
|
136
|
+
const r = await page.request.get(abs);
|
|
137
|
+
if (!r.ok()) { failures.push(`${width}px/${theme}: ${src} → HTTP ${r.status()}`); continue; }
|
|
138
|
+
text = await r.text();
|
|
139
|
+
} catch (e) { failures.push(`${width}px/${theme}: ${src} unfetchable (${e.message})`); continue; }
|
|
140
|
+
|
|
141
|
+
const vb = text.match(/viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/i);
|
|
142
|
+
if (!vb) { rows.push({ width, theme, n: 0, min: null, note: `${src}: no viewBox — cannot scale` }); continue; }
|
|
143
|
+
const scale = renderedW / Number(vb[1]);
|
|
144
|
+
|
|
145
|
+
// Declared sizes: font-size="N" attributes, plus font-size:Npx in the SVG's own <style>.
|
|
146
|
+
const sizes = [
|
|
147
|
+
...[...text.matchAll(/font-size\s*=\s*["']([\d.]+)/gi)].map((m) => Number(m[1])),
|
|
148
|
+
...[...text.matchAll(/font-size\s*:\s*([\d.]+)px/gi)].map((m) => Number(m[1])),
|
|
149
|
+
].filter((n) => n > 0);
|
|
150
|
+
if (!sizes.length) { rows.push({ width, theme, n: 0, min: null, note: `${src}: no font-size found` }); continue; }
|
|
151
|
+
|
|
152
|
+
const smallest = Math.min(...sizes);
|
|
153
|
+
const effective = +(smallest * scale).toFixed(1);
|
|
154
|
+
rows.push({ width, theme, n: sizes.length, min: effective, svgW: Math.round(renderedW),
|
|
155
|
+
note: `${src} (img, viewBox ${vb[1]})` });
|
|
156
|
+
if (effective < MIN) {
|
|
157
|
+
failures.push(`${width}px/${theme}: ${src} smallest label → ${effective}px effective ` +
|
|
158
|
+
`(declared ${smallest} in a ${vb[1]}-unit viewBox rendered at ${Math.round(renderedW)}px) — under ${MIN}px`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!found.length) { rows.push({ width, theme, n: 0, min: null, note: 'no svg <text> found' }); await page.close(); continue; }
|
|
163
|
+
|
|
164
|
+
const worst = found.reduce((a, b) => (b.effective < a.effective ? b : a));
|
|
165
|
+
const bad = found.filter((f) => !f.ok);
|
|
166
|
+
rows.push({ width, theme, n: found.length, min: worst.effective, svgW: worst.svgW, bad: bad.length });
|
|
167
|
+
for (const f of bad) {
|
|
168
|
+
failures.push(`${width}px/${theme}: "${f.text}" → ${f.effective}px effective (declared ${f.declared}, svg rendered ${f.svgW}px) — under ${MIN}px`);
|
|
169
|
+
}
|
|
170
|
+
await page.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await browser.close();
|
|
174
|
+
|
|
175
|
+
console.log(`legibility · ${url} · min ${MIN}px · selector "${selector}"`);
|
|
176
|
+
for (const r of rows) {
|
|
177
|
+
const flag = r.min === null ? '—' : r.min >= MIN ? '✓' : '✗';
|
|
178
|
+
console.log(` ${flag} ${String(r.width).padStart(4)}px/${r.theme.padEnd(5)} ${String(r.n).padStart(3)} labels smallest ${r.min === null ? 'n/a' : r.min + 'px'}${r.note ? ' (' + r.note + ')' : ''}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (failures.length) {
|
|
182
|
+
console.error(`\n✗ ${failures.length} label(s) below ${MIN}px effective — present, unclipped, and unreadable:`);
|
|
183
|
+
for (const f of failures.slice(0, 20)) console.error(' ' + f);
|
|
184
|
+
if (failures.length > 20) console.error(` … and ${failures.length - 20} more`);
|
|
185
|
+
console.error('\n Usually the viewBox is wider than the column it renders into. Size the viewBox to the');
|
|
186
|
+
console.error(' real render width (≈1:1), or give the figure a min-width + overflow-x:auto container.');
|
|
187
|
+
process.exit(3);
|
|
188
|
+
}
|
|
189
|
+
console.log(`\n✓ every label ≥ ${MIN}px effective at every width/theme tested`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/ci/build-fixture-kb.mjs — the fixture-staging logic shared by:
|
|
3
|
+
// • tests/mutation/install-selfcheck-consumption-mutation.test.mjs (M-D8b, local)
|
|
4
|
+
// • .github/workflows/stranger-matrix.yml (ADR-058 §D8, the stranger's-machine matrix)
|
|
5
|
+
//
|
|
6
|
+
// Stages a MINIMAL but REAL KB-bundle directory: the real forge-mcp-all.mjs (copied from kb/ at the
|
|
7
|
+
// candidate SHA — so `--drop-mcp` deleting it for the seeded-broken scenario is a real, meaningful
|
|
8
|
+
// mutation, not a synthetic one), a fake `.rvf` marker (gatherInstallState() only checks EXISTENCE,
|
|
9
|
+
// never content), and `file:`-referenced reader-dep stubs for @xenova/transformers and @ruvector/rvf
|
|
10
|
+
// (installReader() runs a REAL `npm i` inside the unpacked bundle — verified empirically that it
|
|
11
|
+
// PRUNES any node_modules entry not declared as a dependency, even against an otherwise-empty
|
|
12
|
+
// package.json, so a manually-placed stub does not survive; `file:` deps make npm install them for
|
|
13
|
+
// real, from local paths, with zero network and zero real package weight).
|
|
14
|
+
//
|
|
15
|
+
// Deliberately NEVER ships forge-ask-all.mjs: its absence is what keeps bin/install.mjs's
|
|
16
|
+
// smokeQuery() from trying to warm a real local embedding model, keeping every matrix cell fast,
|
|
17
|
+
// offline, and hermetic — the same convention tests/integration/install-smoke.mjs's own "COMPLETE
|
|
18
|
+
// brain dir" fixture and the M-D8b mutation test both already rely on.
|
|
19
|
+
//
|
|
20
|
+
// This script only STAGES a directory — it deliberately does not zip it (zip/unzip tooling differs
|
|
21
|
+
// per OS: `zip -r` on POSIX, PowerShell's `Compress-Archive` on Windows — that one step stays in the
|
|
22
|
+
// workflow YAML, native per runner).
|
|
23
|
+
//
|
|
24
|
+
// node scripts/ci/build-fixture-kb.mjs --out <dir> [--drop-mcp] [--no-rvf]
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
30
|
+
const argv = process.argv.slice(2);
|
|
31
|
+
const arg = (flag, def = null) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
|
|
32
|
+
const OUT = arg('--out');
|
|
33
|
+
const DROP_MCP = argv.includes('--drop-mcp'); // M-D8a: the seeded-broken scenario
|
|
34
|
+
const NO_RVF = argv.includes('--no-rvf');
|
|
35
|
+
|
|
36
|
+
if (!OUT) {
|
|
37
|
+
console.error('usage: node scripts/ci/build-fixture-kb.mjs --out <dir> [--drop-mcp] [--no-rvf]');
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
42
|
+
|
|
43
|
+
if (!DROP_MCP) {
|
|
44
|
+
fs.copyFileSync(path.join(REPO_ROOT, 'kb', 'forge-mcp-all.mjs'), path.join(OUT, 'forge-mcp-all.mjs'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const xenStub = path.join(OUT, 'vendor', 'xenova-transformers-stub');
|
|
48
|
+
fs.mkdirSync(xenStub, { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(xenStub, 'package.json'), '{"name":"@xenova/transformers","version":"0.0.0-fixture"}\n');
|
|
50
|
+
const ruvectorStub = path.join(OUT, 'vendor', 'ruvector-rvf-stub');
|
|
51
|
+
fs.mkdirSync(ruvectorStub, { recursive: true });
|
|
52
|
+
fs.writeFileSync(path.join(ruvectorStub, 'package.json'), '{"name":"@ruvector/rvf","version":"0.0.0-fixture"}\n');
|
|
53
|
+
fs.writeFileSync(path.join(OUT, 'package.json'), JSON.stringify({
|
|
54
|
+
name: 'ruvnet-brain-kb-fixture',
|
|
55
|
+
version: '0.0.0',
|
|
56
|
+
private: true,
|
|
57
|
+
dependencies: {
|
|
58
|
+
'@xenova/transformers': 'file:vendor/xenova-transformers-stub',
|
|
59
|
+
'@ruvector/rvf': 'file:vendor/ruvector-rvf-stub',
|
|
60
|
+
},
|
|
61
|
+
}, null, 2));
|
|
62
|
+
|
|
63
|
+
if (!NO_RVF) {
|
|
64
|
+
fs.writeFileSync(path.join(OUT, 'fixture.rvf'), 'not a real store — presence is what gatherInstallState() counts\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`[build-fixture-kb] staged ${OUT} (mcp: ${!DROP_MCP}, rvf: ${!NO_RVF})`);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fixture-generation adapter used only by scripts/learning-replay.mjs.
|
|
3
|
+
// The installed Codex plugin manifest calls the stable user-level wrapper. RUVNET_BRAIN_HOME makes
|
|
4
|
+
// that wrapper resolve this isolated generation, where this adapter observes the real lesson hook
|
|
5
|
+
// and blocks the first command before the model can learn the CLI syntax from its output.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const raw = fs.readFileSync(0);
|
|
13
|
+
let input = {};
|
|
14
|
+
try { input = JSON.parse(raw.toString('utf8')); } catch { /* fail closed below for tool calls */ }
|
|
15
|
+
|
|
16
|
+
const hookId = process.argv[2] || '';
|
|
17
|
+
const event = String(input.hook_event_name || '');
|
|
18
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const sequenceFile = process.env.RUVNET_REPLAY_SEQUENCE_FILE || '';
|
|
20
|
+
const attemptsFile = process.env.RUVNET_REPLAY_ATTEMPTS_FILE || '';
|
|
21
|
+
const probe = process.env.RUVNET_REPLAY_LESSON_PROBE || '';
|
|
22
|
+
|
|
23
|
+
if (event === 'PreToolUse') {
|
|
24
|
+
const recorder = process.env.RUVNET_REPLAY_RECORDER || '';
|
|
25
|
+
if (!recorder) process.exit(2);
|
|
26
|
+
const result = spawnSync(process.execPath, [recorder, attemptsFile, sequenceFile], {
|
|
27
|
+
input: raw,
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
env: process.env,
|
|
30
|
+
});
|
|
31
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
32
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
33
|
+
process.exit(result.status ?? 2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (event === 'UserPromptSubmit' && hookId === 'unprompted-speech') {
|
|
37
|
+
const shim = path.join(here, 'hook-shim.mjs');
|
|
38
|
+
const result = spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
|
|
39
|
+
input: raw,
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
CLAUDE_SESSION_ID: String(input.session_id || ''),
|
|
44
|
+
CLAUDE_PROJECT_DIR: String(input.cwd || process.cwd()),
|
|
45
|
+
RUVNET_HOOK_HOST: 'codex',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
if (probe && String(result.stdout || '').includes(probe) && sequenceFile) {
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(path.dirname(sequenceFile), { recursive: true });
|
|
51
|
+
fs.appendFileSync(sequenceFile, JSON.stringify({
|
|
52
|
+
kind: 'lesson',
|
|
53
|
+
atNs: process.hrtime.bigint().toString(),
|
|
54
|
+
}) + '\n');
|
|
55
|
+
} catch { /* a missing receipt makes the replay fail closed */ }
|
|
56
|
+
}
|
|
57
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
58
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
59
|
+
process.exit(result.status ?? 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
process.exit(0);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/ci/learning-replay-recorder.mjs — the ARTIFACT TAP for the D4 counterfactual replay.
|
|
3
|
+
//
|
|
4
|
+
// Registered as a PreToolUse(Bash) hook inside a fixture session (never in a user's settings). Its
|
|
5
|
+
// whole job is to capture the command the agent PRODUCED — the artifact the trap's oracle parses —
|
|
6
|
+
// and to stop it from ever executing, because a fixture agent must not run anything on a real
|
|
7
|
+
// machine and must not be allowed to LEARN the answer from a CLI's own output mid-run (a `--help`
|
|
8
|
+
// that reveals the flag would contaminate the control arm and turn a measurable difference into an
|
|
9
|
+
// unmeasurable one).
|
|
10
|
+
//
|
|
11
|
+
// It writes one JSON line per attempt, in order, with a monotonic timestamp. Order is the evidence
|
|
12
|
+
// for PASS-condition (a): the lesson must be in the transcript BEFORE the first of these lines.
|
|
13
|
+
//
|
|
14
|
+
// The payload is parsed by plugin/scripts/hook-input.mjs — the repo's ONE hook-envelope translator
|
|
15
|
+
// (DDD-0013's anti-corruption boundary against Claude Code's hook contract). Never a bash regex,
|
|
16
|
+
// never a hand-rolled JSON.parse of an envelope shape, for the reason that file already records:
|
|
17
|
+
// a JSON-escaped quote silently truncated a command in this exact codebase.
|
|
18
|
+
//
|
|
19
|
+
// Exit 2 + stderr is the documented BLOCK contract. Blocking is the point: the artifact is what the
|
|
20
|
+
// agent WOULD have run, and letting it run would both mutate the machine and feed the agent an
|
|
21
|
+
// answer the trap is trying to measure it for already having.
|
|
22
|
+
//
|
|
23
|
+
// Usage (as a hook): node scripts/ci/learning-replay-recorder.mjs <attempts.jsonl>
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const HOOK_INPUT = path.resolve(HERE, '..', '..', 'plugin', 'scripts', 'hook-input.mjs');
|
|
32
|
+
const out = process.argv[2];
|
|
33
|
+
const sequenceOut = process.argv[3];
|
|
34
|
+
|
|
35
|
+
let payload = '';
|
|
36
|
+
try { payload = fs.readFileSync(0, 'utf8'); } catch { payload = ''; }
|
|
37
|
+
|
|
38
|
+
let command = '';
|
|
39
|
+
const r = spawnSync(process.execPath, [HOOK_INPUT, 'command'], { input: payload, encoding: 'utf8' });
|
|
40
|
+
if (r.status === 0) command = String(r.stdout || '').trim();
|
|
41
|
+
|
|
42
|
+
const atNs = process.hrtime.bigint().toString();
|
|
43
|
+
if (out) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
46
|
+
fs.appendFileSync(out, JSON.stringify({ at: Date.now(), atNs, command }) + '\n');
|
|
47
|
+
} catch { /* a recorder that cannot write must still block; a silent run is worse than a lost line */ }
|
|
48
|
+
}
|
|
49
|
+
if (sequenceOut) {
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(path.dirname(sequenceOut), { recursive: true });
|
|
52
|
+
fs.appendFileSync(sequenceOut, JSON.stringify({ kind: 'tool', atNs, command }) + '\n');
|
|
53
|
+
} catch { /* blocking still wins over a missing receipt */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// stderr is the channel exit 2 feeds back to the model (stdout is ignored on 2). Terse on purpose:
|
|
57
|
+
// a long refusal is itself context, and this fixture is measuring what the agent brings IN.
|
|
58
|
+
process.stderr.write('Sandbox: commands are not executed here. Your command was recorded. Do not retry; state your answer.\n');
|
|
59
|
+
process.exit(2);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/ci/mutate-hook-timeout.mjs — M-D8c (ADR-058 §D8): register ONE additional hook in the
|
|
3
|
+
// PACKED, INSTALLED hooks.json whose body sleeps past its declared timeout, then run
|
|
4
|
+
// `--doctor --hooks` again against the SAME installed surface and assert the battery goes RED.
|
|
5
|
+
// Proves the hook-FIRE assertion is real (a genuine watchdog catch), not manifest-present —
|
|
6
|
+
// tests/integration/install-smoke.mjs's own `--doctor --hooks goes RED when a registered hook
|
|
7
|
+
// sleeps past its declared timeout` test is the fast local rehearsal of this exact mechanism
|
|
8
|
+
// against a synthetic surface; this runs it against the REAL packed-and-installed plugin tree a
|
|
9
|
+
// stranger's machine actually has, right after the `healthy` scenario's install.
|
|
10
|
+
//
|
|
11
|
+
// node scripts/ci/mutate-hook-timeout.mjs --installed <dir> --home <dir>
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const arg = (flag) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
|
|
20
|
+
const INSTALLED = arg('--installed');
|
|
21
|
+
const HOME_DIR = arg('--home'); // the SAME home the `healthy` scenario already installed into
|
|
22
|
+
|
|
23
|
+
if (!INSTALLED || !HOME_DIR) {
|
|
24
|
+
console.error('usage: node mutate-hook-timeout.mjs --installed <dir> --home <dir>');
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pluginRoot = path.join(HOME_DIR, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
|
|
29
|
+
const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
|
|
30
|
+
if (!fs.existsSync(hooksFile)) {
|
|
31
|
+
console.error(`[mutate-hook-timeout] FAIL: no installed hooks.json at ${hooksFile} — run the healthy scenario first`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The REAL held-open-stdin hang fixture this repo already uses to prove the watchdog
|
|
36
|
+
// (tests/fixtures/selfcheck-hooks/hang.mjs) — a synchronous stdin read freezes the event loop, so
|
|
37
|
+
// only an EXTERNAL watchdog (never an in-process timer) can catch it.
|
|
38
|
+
fs.copyFileSync(
|
|
39
|
+
path.join(REPO_ROOT, 'tests', 'fixtures', 'selfcheck-hooks', 'hang.mjs'),
|
|
40
|
+
path.join(pluginRoot, 'scripts', 'ci-hang-fixture.mjs'),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const doc = JSON.parse(fs.readFileSync(hooksFile, 'utf8'));
|
|
44
|
+
doc.hooks ??= {};
|
|
45
|
+
doc.hooks.UserPromptSubmit ??= [];
|
|
46
|
+
doc.hooks.UserPromptSubmit.push({
|
|
47
|
+
matcher: '*',
|
|
48
|
+
hooks: [{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/ci-hang-fixture.mjs"', timeout: 1 }],
|
|
49
|
+
});
|
|
50
|
+
fs.writeFileSync(hooksFile, JSON.stringify(doc, null, 2));
|
|
51
|
+
console.log(`[mutate-hook-timeout] registered a 1s-timeout sleeping hook in ${hooksFile}`);
|
|
52
|
+
|
|
53
|
+
const r = spawnSync(process.execPath, [path.join(INSTALLED, 'bin', 'install.mjs'), '--doctor', '--hooks'], {
|
|
54
|
+
env: { ...process.env, HOME: HOME_DIR, USERPROFILE: HOME_DIR, RUVNET_BRAIN_TEST: '1' },
|
|
55
|
+
input: '',
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 120_000,
|
|
58
|
+
});
|
|
59
|
+
console.log(r.stdout);
|
|
60
|
+
if (r.stderr) console.error(r.stderr);
|
|
61
|
+
|
|
62
|
+
if (r.status === 0) {
|
|
63
|
+
console.error('[mutate-hook-timeout] FAIL: expected --doctor --hooks to go RED with a sleeping hook registered, got exit 0');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
if (!/\bhang\b/.test(r.stdout || '')) {
|
|
67
|
+
console.error('[mutate-hook-timeout] FAIL: exited non-zero, but no "hang" violation was named — some OTHER check may have failed instead');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
console.log(`[mutate-hook-timeout] PASS — the battery went RED (exit ${r.status}) with a real "hang" violation`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stage one scenario's assembled `--local` bundle into the packed install.
|
|
6
|
+
*
|
|
7
|
+
* Scenarios share the installed package directory, so this must replace the
|
|
8
|
+
* previous scenario's bundle rather than merge into it. A merge can preserve a
|
|
9
|
+
* healthy scenario's files and silently repair the seeded-broken fixture.
|
|
10
|
+
*/
|
|
11
|
+
export function stageLocalBundle(sourceDir, installedDir) {
|
|
12
|
+
const targetDir = path.join(installedDir, 'dist', 'ruvnet-brain');
|
|
13
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
14
|
+
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
|
|
15
|
+
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
|
16
|
+
return targetDir;
|
|
17
|
+
}
|