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,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* proxy-verify.mjs — trial-specific verification for the Meta LLM Proxy.
|
|
4
|
+
*
|
|
5
|
+
* DELIBERATELY THIN. rUv already ships the health check:
|
|
6
|
+
*
|
|
7
|
+
* ruflo doctor --component proxy
|
|
8
|
+
*
|
|
9
|
+
* which covers binary signature, version, process liveness (via the proxy's
|
|
10
|
+
* own /status), bind address, and sponsored-consent state (ADR-307/313).
|
|
11
|
+
* This script SHELLS OUT to that rather than reimplementing it — an earlier
|
|
12
|
+
* draft of this file hand-rolled all of it before the grounding gate caught
|
|
13
|
+
* that the real tool existed.
|
|
14
|
+
*
|
|
15
|
+
* It adds exactly two things rUv's doctor does not do, both specific to this
|
|
16
|
+
* trial rather than to the proxy in general:
|
|
17
|
+
*
|
|
18
|
+
* 1. A real end-to-end completion through /v1/messages, checked against the
|
|
19
|
+
* PASSTHROUGH ORACLE. Per ADR-313's addendum, a genuine Anthropic
|
|
20
|
+
* response carries `service_tier` and `cache_creation` in usage; Cognitum's
|
|
21
|
+
* gateway never returns them. Their presence proves the request reached
|
|
22
|
+
* real Anthropic on the user's own subscription rather than being quietly
|
|
23
|
+
* served by a cheap-tier substitute. The doctor confirms the process is
|
|
24
|
+
* healthy; only this proves the round-trip is real.
|
|
25
|
+
*
|
|
26
|
+
* 2. That the trial has NOT been wired globally. This trial is opt-in per
|
|
27
|
+
* session by design; an ANTHROPIC_BASE_URL in ~/.claude/settings.json
|
|
28
|
+
* would put the proxy in the hot path of every Claude Code window on the
|
|
29
|
+
* machine, which is exactly what this trial promised not to do.
|
|
30
|
+
*
|
|
31
|
+
* Exit 0 only if every check passes.
|
|
32
|
+
*/
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
const BASE = 'http://127.0.0.1:11435';
|
|
39
|
+
const TOKEN_PATH = path.join(os.homedir(), '.ruflo', 'proxy-token');
|
|
40
|
+
const PROBE_MODEL = process.env.PROXY_VERIFY_MODEL || 'claude-haiku-4-5-20251001';
|
|
41
|
+
|
|
42
|
+
const checks = [];
|
|
43
|
+
const record = (name, ok, detail) => {
|
|
44
|
+
checks.push({ name, ok });
|
|
45
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
console.log('Meta LLM Proxy — trial verification\n');
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// 1. rUv's shipped health check. We do not reimplement any of it.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
console.log('[1] ruflo doctor --component proxy (rUv\'s own check)');
|
|
54
|
+
let doctorOut = '';
|
|
55
|
+
let doctorOk = false;
|
|
56
|
+
try {
|
|
57
|
+
doctorOut = execFileSync('ruflo', ['doctor', '--component', 'proxy'], {
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
timeout: 60_000,
|
|
60
|
+
});
|
|
61
|
+
doctorOk = /Summary:.*passed/.test(doctorOut) && !/\b\d+ failed/.test(doctorOut);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
doctorOut = err.stdout || String(err);
|
|
64
|
+
}
|
|
65
|
+
for (const line of doctorOut.split('\n')) {
|
|
66
|
+
if (/^[✓✗⚠]/.test(line.trim())) console.log(` ${line.trim()}`);
|
|
67
|
+
}
|
|
68
|
+
record('ruflo proxy doctor reports healthy', doctorOk);
|
|
69
|
+
|
|
70
|
+
// The doctor prints the resolved plane; surface it as its own assertion so a
|
|
71
|
+
// silent flip away from passthrough can never pass unnoticed.
|
|
72
|
+
record('data plane is passthrough', /data plane:\s*passthrough/i.test(doctorOut));
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// 2. The round-trip the doctor does not perform.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
console.log('\n[2] live round-trip + passthrough oracle');
|
|
78
|
+
let token = '';
|
|
79
|
+
try {
|
|
80
|
+
token = fs.readFileSync(TOKEN_PATH, 'utf8').trim();
|
|
81
|
+
} catch {
|
|
82
|
+
/* handled below */
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!token) {
|
|
86
|
+
record('live round-trip returns correct content', false, 'no proxy token');
|
|
87
|
+
record('response proves REAL Anthropic', false, 'skipped');
|
|
88
|
+
} else {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${BASE}/v1/messages`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
authorization: `Bearer ${token}`,
|
|
94
|
+
'content-type': 'application/json',
|
|
95
|
+
'anthropic-version': '2023-06-01',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
model: PROBE_MODEL,
|
|
99
|
+
max_tokens: 16,
|
|
100
|
+
messages: [{ role: 'user', content: 'Reply with exactly: PROXY_OK' }],
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(60_000),
|
|
103
|
+
});
|
|
104
|
+
const body = await res.json();
|
|
105
|
+
const text = body?.content?.[0]?.text ?? '';
|
|
106
|
+
record('live round-trip returns correct content', res.ok && text.includes('PROXY_OK'), `HTTP ${res.status}`);
|
|
107
|
+
|
|
108
|
+
const usage = body?.usage ?? {};
|
|
109
|
+
const oracleOk = 'service_tier' in usage && 'cache_creation' in usage;
|
|
110
|
+
record(
|
|
111
|
+
'response proves REAL Anthropic (service_tier + cache_creation)',
|
|
112
|
+
oracleOk,
|
|
113
|
+
oracleOk ? `service_tier=${usage.service_tier}` : 'oracle fields ABSENT — did not reach Anthropic',
|
|
114
|
+
);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
record('live round-trip returns correct content', false, String(err?.message || err));
|
|
117
|
+
record('response proves REAL Anthropic', false, 'no response');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// 3. Trial safety: opt-in per session, never global.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
console.log('\n[3] trial safety');
|
|
125
|
+
let noGlobalWiring = true;
|
|
126
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
127
|
+
try {
|
|
128
|
+
if (fs.existsSync(settingsPath)) {
|
|
129
|
+
noGlobalWiring = !fs.readFileSync(settingsPath, 'utf8').includes('ANTHROPIC_BASE_URL');
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
/* absent file is fine */
|
|
133
|
+
}
|
|
134
|
+
record('NOT wired globally (~/.claude/settings.json clean)', noGlobalWiring);
|
|
135
|
+
|
|
136
|
+
const failed = checks.filter((c) => !c.ok);
|
|
137
|
+
console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`);
|
|
138
|
+
if (failed.length) {
|
|
139
|
+
console.log(`FAILED: ${failed.map((f) => f.name).join(', ')}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
console.log('Verified: proxy healthy, passthrough, real Anthropic round-trip, not wired globally.');
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* published-surface-probe.mjs — touch the surface a REAL user touches, on a schedule.
|
|
4
|
+
*
|
|
5
|
+
* THE DEDUCTION THIS CLOSES (ADR-058 D2, grader verbatim): "Zero `scheduled-live-probe` scenarios
|
|
6
|
+
* (I listed all 22: 19 ci, 3 manual). Nothing ever probes the *published* surface (registry `npx`,
|
|
7
|
+
* real Release download) on a schedule — the exact 'dead on the surface a real user touched'
|
|
8
|
+
* failure ADR-053 names."
|
|
9
|
+
*
|
|
10
|
+
* Every one of the 19 `ci` scenarios runs against THE SOURCE CHECKOUT. That is a different artifact
|
|
11
|
+
* from the one a stranger receives. A green CI and a dead `npx ruvnet-brain@latest` are perfectly
|
|
12
|
+
* compatible states, and the gap between them is not hypothetical — it is produced by ordinary,
|
|
13
|
+
* boring events that no push triggers:
|
|
14
|
+
*
|
|
15
|
+
* · a `files:` array that omits a file the CLI requires — the checkout has it, the tarball does not
|
|
16
|
+
* · an `npm publish` that half-succeeded, or a version unpublished/deprecated out from under us
|
|
17
|
+
* · a Release asset deleted, re-uploaded truncated, or a tag moved
|
|
18
|
+
* · a dependency that vanished from the registry
|
|
19
|
+
*
|
|
20
|
+
* None of those change a byte in this repo, so no push-triggered workflow can ever notice. Only a
|
|
21
|
+
* clock can. That is why this runs nightly and NOT on push.
|
|
22
|
+
*
|
|
23
|
+
* ── WHAT MAKES IT RED (stated plainly, because a probe that cannot fail is theater) ──────────────
|
|
24
|
+
* A1 the npm packument for `ruvnet-brain` is not fetchable, or carries no `dist-tags.latest`
|
|
25
|
+
* A2 the latest version's tarball URL does not HEAD 200 with a non-zero length
|
|
26
|
+
* B `npx -y ruvnet-brain@latest --help` — installed FROM THE REGISTRY into a throwaway prefix —
|
|
27
|
+
* exits non-zero, or prints something that is not this installer's help. This is the check
|
|
28
|
+
* that catches a broken `files:`/`bin:` mapping, a syntax error, or a missing runtime dep:
|
|
29
|
+
* the published package is EXECUTED, not merely inspected.
|
|
30
|
+
* C1 `releases/latest` exposes no `ruvnet-brain.zip`
|
|
31
|
+
* C2 that asset is implausibly small (< MIN_BUNDLE_BYTES) — a truncated re-upload is a real and
|
|
32
|
+
* silent failure mode; "the asset exists" is not the same as "the asset is the bundle"
|
|
33
|
+
* C3 its browser_download_url does not resolve 200 with a matching content-length
|
|
34
|
+
* C4 the `.zip.sha256` sidecar is missing or is not a well-formed 64-hex digest — the installer
|
|
35
|
+
* verifies against it, so a malformed sidecar breaks every fresh install
|
|
36
|
+
*
|
|
37
|
+
* ── WHAT IS *NOT* RED, ON PURPOSE ───────────────────────────────────────────────────────────────
|
|
38
|
+
* Version equality is a product invariant: npm dist-tags.latest and GitHub releases/latest must
|
|
39
|
+
* name the same Brain generation. A partial publish is red even when both artifacts work alone.
|
|
40
|
+
*
|
|
41
|
+
* ── UNKNOWN IS NEVER PASS ───────────────────────────────────────────────────────────────────────
|
|
42
|
+
* A rate-limited or unreachable network cannot distinguish "the surface is fine" from "the surface
|
|
43
|
+
* is gone". That is UNKNOWN (exit 4), not green. Same discipline as scripts/learning-replay.mjs.
|
|
44
|
+
*
|
|
45
|
+
* node scripts/published-surface-probe.mjs full probe (network + a real npx install)
|
|
46
|
+
* node scripts/published-surface-probe.mjs --no-exec skip check B (metadata only; much faster)
|
|
47
|
+
* node scripts/published-surface-probe.mjs --json machine-readable result on stdout
|
|
48
|
+
*
|
|
49
|
+
* Exit: 0 PASS · 1 FAIL · 4 UNKNOWN.
|
|
50
|
+
*/
|
|
51
|
+
import fs from 'node:fs';
|
|
52
|
+
import os from 'node:os';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { spawnSync } from 'node:child_process';
|
|
55
|
+
|
|
56
|
+
export const EXIT = Object.freeze({ PASS: 0, FAIL: 1, UNKNOWN: 4 });
|
|
57
|
+
|
|
58
|
+
const PKG = 'ruvnet-brain';
|
|
59
|
+
const REPO = 'stuinfla/ruvnet-brain';
|
|
60
|
+
const ASSET = 'ruvnet-brain.zip';
|
|
61
|
+
const REGISTRY = `https://registry.npmjs.org/${PKG}`;
|
|
62
|
+
const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bundle is ~736MB-845MB today. The floor is set FAR below that — this is a truncation
|
|
66
|
+
* detector, not a size assertion; the bundle is allowed to grow or shrink substantially without
|
|
67
|
+
* anyone having to come edit this number. Anything under 100MB is not the brain.
|
|
68
|
+
*/
|
|
69
|
+
const MIN_BUNDLE_BYTES = 100 * 1024 * 1024;
|
|
70
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
71
|
+
/** The published --help must be OUR help. A registry that serves a name-squatted package is red. */
|
|
72
|
+
const HELP_MARKERS = ['RuvNet Brain installer', 'npx ruvnet-brain'];
|
|
73
|
+
|
|
74
|
+
const argv = process.argv.slice(2);
|
|
75
|
+
const NO_EXEC = argv.includes('--no-exec');
|
|
76
|
+
const AS_JSON = argv.includes('--json');
|
|
77
|
+
|
|
78
|
+
const checks = [];
|
|
79
|
+
const record = (id, status, detail) => { checks.push({ id, status, detail }); return status; };
|
|
80
|
+
const say = (...a) => { if (!AS_JSON) console.log(...a); };
|
|
81
|
+
|
|
82
|
+
/** fetch + classify. A transport error is UNKNOWN; a 4xx/5xx from a reachable host is a fact. */
|
|
83
|
+
async function get(url, { method = 'GET', headers = {} } = {}) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(url, { method, headers: { 'user-agent': `${PKG}-published-surface-probe`, ...headers }, redirect: 'follow' });
|
|
86
|
+
return { ok: true, status: res.status, res };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, error: String(e?.message || e) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── A. the npm registry surface ────────────────────────────────────────────────────────────────
|
|
93
|
+
async function probeRegistry() {
|
|
94
|
+
const r = await get(REGISTRY);
|
|
95
|
+
if (!r.ok) return { latest: null, s: record('A1-registry', 'UNKNOWN', `registry unreachable: ${r.error}`) };
|
|
96
|
+
if (r.status === 404) return { latest: null, s: record('A1-registry', 'FAIL', `${PKG} is NOT PUBLISHED (registry 404) — every \`npx ${PKG}\` on earth is dead`) };
|
|
97
|
+
if (r.status !== 200) return { latest: null, s: record('A1-registry', 'UNKNOWN', `registry returned HTTP ${r.status}`) };
|
|
98
|
+
|
|
99
|
+
let doc;
|
|
100
|
+
try { doc = await r.res.json(); } catch (e) { return { latest: null, s: record('A1-registry', 'FAIL', `registry returned unparseable JSON: ${e.message}`) }; }
|
|
101
|
+
|
|
102
|
+
const latest = doc?.['dist-tags']?.latest;
|
|
103
|
+
if (!latest) return { latest: null, s: record('A1-registry', 'FAIL', 'no dist-tags.latest — `npx pkg@latest` cannot resolve') };
|
|
104
|
+
const versionDoc = doc?.versions?.[latest];
|
|
105
|
+
if (!versionDoc) return { latest, s: record('A1-registry', 'FAIL', `dist-tags.latest=${latest} but no such version object — the tag points at nothing`) };
|
|
106
|
+
const tarball = versionDoc?.dist?.tarball;
|
|
107
|
+
if (!tarball) return { latest, s: record('A1-registry', 'FAIL', `${latest} carries no dist.tarball`) };
|
|
108
|
+
record('A1-registry', 'PASS', `dist-tags.latest=${latest}`);
|
|
109
|
+
|
|
110
|
+
// PROVE BYTES, NOT HEADERS. The first version of this check asserted a non-zero content-length on
|
|
111
|
+
// a HEAD, and went red against a perfectly healthy registry: verified live 2026-07-28, npm answers
|
|
112
|
+
// HEAD on a tarball with `HTTP/2 200` and NO content-length at all. That was a harness defect
|
|
113
|
+
// reporting itself as a surface outage — the precise false-red that teaches people to ignore a
|
|
114
|
+
// nightly. So: fetch a real byte range and check it is actually a gzip (npm tarballs are .tgz).
|
|
115
|
+
// Stronger than a length header anyway — it catches a truncated or garbage upload, which a
|
|
116
|
+
// correct-looking content-length would sail straight past.
|
|
117
|
+
const range = await get(tarball, { headers: { range: 'bytes=0-1023' } });
|
|
118
|
+
if (!range.ok) return { latest, s: record('A2-tarball', 'UNKNOWN', `tarball fetch failed: ${range.error}`) };
|
|
119
|
+
if (range.status !== 200 && range.status !== 206) return { latest, s: record('A2-tarball', 'FAIL', `tarball HTTP ${range.status} → ${tarball}`) };
|
|
120
|
+
const head4 = new Uint8Array(await range.res.arrayBuffer());
|
|
121
|
+
if (head4.length === 0) return { latest, s: record('A2-tarball', 'FAIL', `tarball served zero bytes → ${tarball}`) };
|
|
122
|
+
if (head4[0] !== 0x1f || head4[1] !== 0x8b) {
|
|
123
|
+
return { latest, s: record('A2-tarball', 'FAIL', `tarball is not gzip (first bytes ${head4[0]?.toString(16)} ${head4[1]?.toString(16)}) → ${tarball}`) };
|
|
124
|
+
}
|
|
125
|
+
record('A2-tarball', 'PASS', `${head4.length} bytes served, gzip magic ok`);
|
|
126
|
+
return { latest, s: 'PASS' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── B. EXECUTE the published package, from the registry ────────────────────────────────────────
|
|
130
|
+
function probeExec() {
|
|
131
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-probe-'));
|
|
132
|
+
try {
|
|
133
|
+
// --help is chosen deliberately: it is the one flag that exercises the real published entrypoint
|
|
134
|
+
// (npm resolve → download → unpack → bin mapping → node parses every module it imports) while
|
|
135
|
+
// touching NOTHING on the machine. `--doctor`/`--plan` reach the network and the brain dir; this
|
|
136
|
+
// probe must never be the reason a surface changes.
|
|
137
|
+
const r = spawnSync('npx', ['-y', `${PKG}@latest`, '--help'], {
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
timeout: 300_000,
|
|
140
|
+
cwd: home,
|
|
141
|
+
env: { ...process.env, HOME: home, npm_config_yes: 'true', NO_COLOR: '1' },
|
|
142
|
+
});
|
|
143
|
+
if (r.error) return record('B-npx-exec', 'UNKNOWN', `could not spawn npx: ${r.error.message}`);
|
|
144
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
|
145
|
+
if (r.status !== 0) {
|
|
146
|
+
return record('B-npx-exec', 'FAIL', `\`npx -y ${PKG}@latest --help\` exited ${r.status}. First 400 chars:\n${out.slice(0, 400)}`);
|
|
147
|
+
}
|
|
148
|
+
const missing = HELP_MARKERS.filter((m) => !out.includes(m));
|
|
149
|
+
if (missing.length) {
|
|
150
|
+
return record('B-npx-exec', 'FAIL', `published --help ran but does not look like this installer (missing: ${missing.join(', ')}). First 400 chars:\n${out.slice(0, 400)}`);
|
|
151
|
+
}
|
|
152
|
+
return record('B-npx-exec', 'PASS', `\`npx -y ${PKG}@latest --help\` exited 0 and printed this installer's help`);
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── C. the GitHub Release surface the installer actually downloads ─────────────────────────────
|
|
159
|
+
async function probeRelease() {
|
|
160
|
+
const headers = process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {};
|
|
161
|
+
const r = await get(RELEASE_API, { headers });
|
|
162
|
+
if (!r.ok) return { tag: null, s: record('C1-release', 'UNKNOWN', `GitHub unreachable: ${r.error}`) };
|
|
163
|
+
if (r.status === 403 || r.status === 429) return { tag: null, s: record('C1-release', 'UNKNOWN', `GitHub rate-limited (HTTP ${r.status}) — cannot distinguish healthy from gone`) };
|
|
164
|
+
if (r.status === 404) return { tag: null, s: record('C1-release', 'FAIL', `${REPO} has NO published Release — a fresh install has nothing to download`) };
|
|
165
|
+
if (r.status !== 200) return { tag: null, s: record('C1-release', 'UNKNOWN', `releases/latest returned HTTP ${r.status}`) };
|
|
166
|
+
|
|
167
|
+
let rel;
|
|
168
|
+
try { rel = await r.res.json(); } catch (e) { return { tag: null, s: record('C1-release', 'FAIL', `releases/latest unparseable: ${e.message}`) }; }
|
|
169
|
+
const tag = rel?.tag_name || null;
|
|
170
|
+
const assets = Array.isArray(rel?.assets) ? rel.assets : [];
|
|
171
|
+
const zip = assets.find((a) => a.name === ASSET);
|
|
172
|
+
if (!zip) {
|
|
173
|
+
return { tag, s: record('C1-release', 'FAIL', `Release ${tag} has no "${ASSET}" (has: ${assets.map((a) => a.name).join(', ') || 'nothing'})`) };
|
|
174
|
+
}
|
|
175
|
+
record('C1-release', 'PASS', `Release ${tag} carries ${ASSET}`);
|
|
176
|
+
|
|
177
|
+
if (!(zip.size >= MIN_BUNDLE_BYTES)) {
|
|
178
|
+
return { tag, s: record('C2-bundle-size', 'FAIL', `${ASSET} is ${zip.size} bytes — below the ${MIN_BUNDLE_BYTES}-byte truncation floor; this is not the brain`) };
|
|
179
|
+
}
|
|
180
|
+
record('C2-bundle-size', 'PASS', `${zip.size} bytes`);
|
|
181
|
+
|
|
182
|
+
const head = await get(zip.browser_download_url, { method: 'HEAD' });
|
|
183
|
+
if (!head.ok) return { tag, s: record('C3-bundle-download', 'UNKNOWN', `bundle HEAD failed: ${head.error}`) };
|
|
184
|
+
if (head.status !== 200) return { tag, s: record('C3-bundle-download', 'FAIL', `bundle download URL returned HTTP ${head.status} — the asset is listed but not fetchable`) };
|
|
185
|
+
const len = Number(head.res.headers.get('content-length') || 0);
|
|
186
|
+
if (len && Math.abs(len - zip.size) > 0) {
|
|
187
|
+
return { tag, s: record('C3-bundle-download', 'FAIL', `served length ${len} != API-declared size ${zip.size} — the asset on the CDN is not the asset in the Release`) };
|
|
188
|
+
}
|
|
189
|
+
record('C3-bundle-download', 'PASS', `HTTP 200, ${len || zip.size} bytes`);
|
|
190
|
+
|
|
191
|
+
const sidecar = assets.find((a) => a.name === `${ASSET}.sha256`);
|
|
192
|
+
if (!sidecar) return { tag, s: record('C4-sha256-sidecar', 'FAIL', `no ${ASSET}.sha256 — the installer has nothing to verify the 800MB download against`) };
|
|
193
|
+
const sc = await get(sidecar.browser_download_url);
|
|
194
|
+
if (!sc.ok) return { tag, s: record('C4-sha256-sidecar', 'UNKNOWN', `sidecar fetch failed: ${sc.error}`) };
|
|
195
|
+
if (sc.status !== 200) return { tag, s: record('C4-sha256-sidecar', 'FAIL', `sidecar returned HTTP ${sc.status}`) };
|
|
196
|
+
const digest = (await sc.res.text()).trim().split(/\s+/)[0] || '';
|
|
197
|
+
if (!SHA256_RE.test(digest)) {
|
|
198
|
+
return { tag, s: record('C4-sha256-sidecar', 'FAIL', `sidecar is not a well-formed sha256 digest (got ${JSON.stringify(digest.slice(0, 80))})`) };
|
|
199
|
+
}
|
|
200
|
+
record('C4-sha256-sidecar', 'PASS', `${digest.slice(0, 16)}…`);
|
|
201
|
+
return { tag, s: 'PASS' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function main() {
|
|
205
|
+
say('PUBLISHED-SURFACE probe — the artifact a stranger receives, not the one in this checkout\n');
|
|
206
|
+
|
|
207
|
+
const reg = await probeRegistry();
|
|
208
|
+
if (!NO_EXEC) probeExec();
|
|
209
|
+
else record('B-npx-exec', 'SKIPPED', '--no-exec');
|
|
210
|
+
const rel = await probeRelease();
|
|
211
|
+
|
|
212
|
+
if (reg.latest && rel.tag) {
|
|
213
|
+
const githubVersion = String(rel.tag).replace(/^v/, '');
|
|
214
|
+
if (reg.latest === githubVersion) {
|
|
215
|
+
record('D-version-coherence', 'PASS', `npm ${reg.latest} == GitHub ${rel.tag}`);
|
|
216
|
+
} else {
|
|
217
|
+
record('D-version-coherence', 'FAIL', `npm ${reg.latest} != GitHub ${rel.tag} — published surfaces identify different Brain generations`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
record('D-version-coherence', 'UNKNOWN', 'npm or GitHub version unavailable; equality cannot be proven');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const c of checks) say(` ${c.status.padEnd(7)} ${c.id.padEnd(20)} ${c.detail}`);
|
|
224
|
+
|
|
225
|
+
say(`\n npm dist-tags.latest = ${reg.latest ?? '?'} · GitHub releases/latest = ${rel.tag ?? '?'} (must match)`);
|
|
226
|
+
|
|
227
|
+
const failed = checks.filter((c) => c.status === 'FAIL');
|
|
228
|
+
const unknown = checks.filter((c) => c.status === 'UNKNOWN');
|
|
229
|
+
const verdict = failed.length ? 'FAIL' : unknown.length ? 'UNKNOWN' : 'PASS';
|
|
230
|
+
|
|
231
|
+
if (AS_JSON) console.log(JSON.stringify({ verdict, npmLatest: reg.latest, releaseTag: rel.tag, checks }, null, 2));
|
|
232
|
+
else {
|
|
233
|
+
say(`\n PUBLISHED-SURFACE: ${verdict}`);
|
|
234
|
+
if (failed.length) say(` ${failed.length} check(s) FAILED — the published surface is broken for real users RIGHT NOW.`);
|
|
235
|
+
if (!failed.length && unknown.length) say(` ${unknown.length} check(s) UNKNOWN — a probe that could not measure is not a pass.`);
|
|
236
|
+
}
|
|
237
|
+
return EXIT[verdict];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
|
|
241
|
+
if (invokedDirectly) process.exit(await main());
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// card-lane-gate.mjs — ADR-058 D6's HARD gate over kb/card-lane.mjs's decision lane.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS A HARD GATE WHEN THE REST OF ux-suite.mjs's TIMINGS ARE ADVISORY: ux-suite.mjs's own
|
|
5
|
+
// header argues, correctly, that a flaky timing gate trains people to override it — server-ready,
|
|
6
|
+
// console paint, etc. are all subject to real environmental noise (cold node boot, first-paint,
|
|
7
|
+
// disk cache state) that has nothing to do with whether the product is correct. kb/card-lane.mjs's
|
|
8
|
+
// decision lane is different in kind, not degree: it is MODEL-FREE, ML-FREE, keyword overlap over a
|
|
9
|
+
// ~20KB in-memory-cached file (measured 0.1158ms warm — see kb/card-lane-budget.json). A budget set
|
|
10
|
+
// at ~2,159x that baseline (250ms) and an absolute ceiling at ~8,600x it (1000ms) leaves so much
|
|
11
|
+
// headroom that a breach cannot be scheduler jitter — it can only be a correctness regression (an
|
|
12
|
+
// accidental await, a removed cache, a blocking fs call in the hot path). That is exactly the case
|
|
13
|
+
// this file's own anti-flake rule permits hard-gating, and is why this is a SEPARATE, narrow gate
|
|
14
|
+
// rather than an entry in the shared WARN table.
|
|
15
|
+
//
|
|
16
|
+
// MEASUREMENT METHOD, DELIBERATE (CI CONSTRAINT): this measures IN-PROCESS function calls only — no
|
|
17
|
+
// `spawnSync` per firing. A GitHub ubuntu runner has 2 vCPU against this dev machine's 16, and
|
|
18
|
+
// subprocess-per-firing measurement has already produced starved, silently-empty output on that
|
|
19
|
+
// runner twice tonight. In-process calls have no fork()/exec() cost and no OS scheduling of a new
|
|
20
|
+
// process per sample, so the number this file reports is the LANE's cost, not the scheduler's. If
|
|
21
|
+
// this ever needs to spawn a subprocess instead, the budget below MUST be re-derived and explicitly
|
|
22
|
+
// re-sized for a 2 vCPU runner — do not silently keep a 16-core-measured number for a 2 vCPU gate.
|
|
23
|
+
//
|
|
24
|
+
// THE THRESHOLDS ARE NOT HARDCODED HERE. They live in the checked-in manifest kb/card-lane-budget.json,
|
|
25
|
+
// which docs/adr/0058-the-95-contract.md `governs:` — so a silent threshold raise there shows up as
|
|
26
|
+
// governed-set drift under `node scripts/doc-currency.mjs --check` rather than being a free edit.
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { answerFromCards } from '../../kb/card-lane.mjs';
|
|
31
|
+
|
|
32
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
export const REPO_ROOT = path.resolve(HERE, '../..');
|
|
34
|
+
export const KB_DIR = path.join(REPO_ROOT, 'kb');
|
|
35
|
+
export const BUDGET_PATH = path.join(KB_DIR, 'card-lane-budget.json');
|
|
36
|
+
|
|
37
|
+
// Real, first-party questions (plugin/test/capability-questions.json) rather than an invented
|
|
38
|
+
// string — the lane's cost should not depend on which of these it is asked, and cycling several
|
|
39
|
+
// (rather than one) avoids over-fitting the measurement to a single query's token count.
|
|
40
|
+
const FALLBACK_QUERIES = [
|
|
41
|
+
'Can ruflo orchestrate agent swarms?',
|
|
42
|
+
'Does RuVector use HNSW for vector search?',
|
|
43
|
+
'Can rUv building blocks run graph queries over agent memory?',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function loadQueries() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'plugin/test/capability-questions.json'), 'utf8'));
|
|
49
|
+
const qs = raw.map((r) => r.query).filter(Boolean);
|
|
50
|
+
return qs.length ? qs.slice(0, 5) : FALLBACK_QUERIES;
|
|
51
|
+
} catch {
|
|
52
|
+
return FALLBACK_QUERIES; // absence of the fixture must not sink the gate — it has its own tests
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadBudget(budgetPath = BUDGET_PATH) {
|
|
57
|
+
const raw = fs.readFileSync(budgetPath, 'utf8');
|
|
58
|
+
const budget = JSON.parse(raw);
|
|
59
|
+
for (const key of ['sampleSize', 'p95BudgetMs', 'absoluteFailMs']) {
|
|
60
|
+
if (typeof budget[key] !== 'number' || !(budget[key] > 0)) {
|
|
61
|
+
throw new Error(`card-lane-budget.json: "${key}" must be a positive number, got ${JSON.stringify(budget[key])}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return budget;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Nearest-rank percentile over an ASCENDING-sorted array. p in [0,100].
|
|
68
|
+
export function percentile(sortedAsc, p) {
|
|
69
|
+
if (!sortedAsc.length) return null;
|
|
70
|
+
const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.ceil((p / 100) * sortedAsc.length) - 1));
|
|
71
|
+
return sortedAsc[idx];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fire the decision lane `n` times, IN-PROCESS, and return each firing's wall time in ms.
|
|
76
|
+
* `answerFn` defaults to the real `answerFromCards` and exists as an injectable seam ONLY so
|
|
77
|
+
* tests/unit/card-lane-gate.test.mjs can prove the THRESHOLD LOGIC catches a slow lane using a real
|
|
78
|
+
* (setTimeout-based, not mocked-away) synthetic function, without needing to mutate the shipped
|
|
79
|
+
* kb/card-lane.mjs to do it — that file's own mutant is exercised separately, for real, per ADR-058.
|
|
80
|
+
* Tolerates `answerFn` returning either a plain object (today's shipped shape) or a thenable (what a
|
|
81
|
+
* mutant that inserts `await new Promise(...)` inside it would produce) — the timing loop must
|
|
82
|
+
* actually wait on the delay for the mutant to be observable at all.
|
|
83
|
+
*/
|
|
84
|
+
export async function measureFirings({ dir = KB_DIR, queries = loadQueries(), n = 100, answerFn = answerFromCards } = {}) {
|
|
85
|
+
if (!queries.length) throw new Error('measureFirings: no queries to fire');
|
|
86
|
+
// Warm-up: one untimed call, matching the lane's own memoization (kb/card-lane.mjs's `_cache`) so
|
|
87
|
+
// the measured 100 firings reflect the WARM cost, not the one-time capability-cards.md parse.
|
|
88
|
+
const warm = answerFn(queries[0], dir);
|
|
89
|
+
if (warm && typeof warm.then === 'function') await warm;
|
|
90
|
+
|
|
91
|
+
const samplesMs = [];
|
|
92
|
+
for (let i = 0; i < n; i++) {
|
|
93
|
+
const q = queries[i % queries.length];
|
|
94
|
+
const t0 = process.hrtime.bigint();
|
|
95
|
+
const result = answerFn(q, dir);
|
|
96
|
+
if (result && typeof result.then === 'function') await result;
|
|
97
|
+
const t1 = process.hrtime.bigint();
|
|
98
|
+
samplesMs.push(Number(t1 - t0) / 1e6);
|
|
99
|
+
}
|
|
100
|
+
return samplesMs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The gate. Returns a verdict object; never throws on a threshold breach (that is a normal result,
|
|
105
|
+
* not an exceptional one) — it throws only if the lane or the manifest could not run at all, which
|
|
106
|
+
* scripts/qe/ux-suite.mjs treats as its own hard failure ("could not measure" is never success).
|
|
107
|
+
*/
|
|
108
|
+
export async function runCardLaneGate(opts = {}) {
|
|
109
|
+
const budget = loadBudget(opts.budgetPath);
|
|
110
|
+
const samplesMs = await measureFirings({ dir: opts.dir, queries: opts.queries, n: budget.sampleSize, answerFn: opts.answerFn });
|
|
111
|
+
const sorted = [...samplesMs].sort((a, b) => a - b);
|
|
112
|
+
const p50 = percentile(sorted, 50);
|
|
113
|
+
const p95 = percentile(sorted, 95);
|
|
114
|
+
const max = sorted[sorted.length - 1];
|
|
115
|
+
|
|
116
|
+
const reasons = [];
|
|
117
|
+
if (p95 > budget.absoluteFailMs || max > budget.absoluteFailMs) {
|
|
118
|
+
reasons.push(`ABSOLUTE FAIL — correctness event, not jitter: max=${max.toFixed(4)}ms p95=${p95.toFixed(4)}ms > absoluteFailMs=${budget.absoluteFailMs}ms (${budget.measuredBaseline?.warmMs != null ? `~${(budget.absoluteFailMs / budget.measuredBaseline.warmMs).toFixed(0)}x the measured ${budget.measuredBaseline.warmMs}ms warm baseline` : 'far above the measured baseline'})`);
|
|
119
|
+
} else if (p95 > budget.p95BudgetMs) {
|
|
120
|
+
reasons.push(`BUDGET BREACH: p95=${p95.toFixed(4)}ms > p95BudgetMs=${budget.p95BudgetMs}ms over ${budget.sampleSize} in-process firings`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
pass: reasons.length === 0,
|
|
125
|
+
n: samplesMs.length,
|
|
126
|
+
p50, p95, max,
|
|
127
|
+
budget,
|
|
128
|
+
reasons,
|
|
129
|
+
samplesMs,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
function fmt(ms) { return `${ms.toFixed(4)}ms`; }
|
|
135
|
+
|
|
136
|
+
async function main() {
|
|
137
|
+
console.log('\n card-lane decision-lane latency gate (ADR-058 D6 — HARD gate, in-process, model-free)\n');
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = await runCardLaneGate();
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(` ✗ could not run the gate: ${e.message}`);
|
|
143
|
+
process.exit(2);
|
|
144
|
+
}
|
|
145
|
+
console.log(` budget source kb/card-lane-budget.json`);
|
|
146
|
+
console.log(` firings ${result.n} (in-process, no subprocess per firing)`);
|
|
147
|
+
console.log(` p50 ${fmt(result.p50)}`);
|
|
148
|
+
console.log(` p95 ${fmt(result.p95)} (budget ${result.budget.p95BudgetMs}ms)`);
|
|
149
|
+
console.log(` max ${fmt(result.max)} (absolute fail ${result.budget.absoluteFailMs}ms)`);
|
|
150
|
+
console.log('');
|
|
151
|
+
if (result.pass) {
|
|
152
|
+
console.log(' PASS — decision lane inside budget.\n');
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
console.log(' FAIL (hard):');
|
|
156
|
+
for (const r of result.reasons) console.log(` ✗ ${r}`);
|
|
157
|
+
console.log('');
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
162
|
+
if (invokedDirectly) main();
|