igneum/tools/ci/launch-gates-check.mjs
igneum-labs 00a8d244b7 Launch pack tools: per-tier income table from the bench rows and TESTNET_1, the daily hash-origin report, the launch-gates check (mission item 10)
tools/launch/income-tiers.mjs renders docs/analysis/income-tiers.md (public) from tools/launch/income-tiers.json: eleven measured cards (the 6 October rented-card rows, the 9070 XT telemetry run, the M5 Max Metal bench, each with its source), IGN a day at 1, 10 and 100 GH/s after the ramp on EmissionSchedule::TESTNET_1 (100 IGN a block, 90-day ramp from 10 percent, monthly 2^(-1/24), the 80 percent producer share), electricity a day and per mined IGN at USD 0.005, 0.02 and 0.10 a kWh, a rig and a pool-user line, the consequences per tier, the owed rows; --check fails CI when the page and its inputs disagree; the test pins the arithmetic (6,912 IGN a day for 100 MH/s on 100 GH/s at the launch rate, day 1 at 10 percent, one step at 2^(-1/24)).

tools/observer/hash-origin.mjs: once a day from the observer's tables, who found the blocks: keys with a block and above dust, attested pools against shared payout addresses (a multi-key machine is not a pool until its operator attests), the project fleet's share from the intake identity lines plus the fleet registry's key file, the ten largest keys, the 2x step since yesterday, the community gates (first 100 keys, first outside block, first attested outside pool at 10 percent for 7 days; the X5 count stays an upper bound until the two observer columns exist). --dry reads only; --write keeps hash_origin_days and hash_origin_reports and live_state.hash_origin; --post goes through the Discord poster's guard. Fixture, a known-finished and a known-failed day in the test. Ran read-only on the live devnet today: 113 keys, 0.76 GH/s, the fleet share wrong until the key file exists.

tools/ci/launch-gates-check.mjs (in pre-push.sh with the two test lines): every row of the Launch gates table in testnet-go.md has a check and every backticked path exists; the site-lane handoff and the income table carry no served-page or export pattern and no em dash; the eight regulatory sentences are present in order under the label. Self-test fires on each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:53:12 +00:00

135 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
// The launch-pack gate (mission item 10, docs/analysis/mission/mission.md 2.10; the rule-row rule of CLAUDE.md,
// 6 October 2026: a rule row closes only with its check). Three things, each an exit 1 with the line:
// 1. every row of the "Launch gates" table in docs/plans/testnet-go.md has a non-empty Check cell, and every repository
// path the cell names in backticks exists (a gate whose check names a script that is not there is not a gate);
// 2. the text handed to the site lane (docs/plans/launch-pack.md between <!-- handoff:start --> and <!-- handoff:end -->)
// and the public income table (docs/analysis/income-tiers.md) carry none of the served-page patterns of
// site/forbidden-strings.txt nor the export patterns of tools/ci/forbidden-strings.txt, and no em dash;
// 3. the eight regulatory sentences in the handoff are the eight of docs/analysis/mission/future.md section 8.3, each
// numbered, in order, none dropped (the handoff may rephrase; each must cite its number).
// node tools/ci/launch-gates-check.mjs # exit 1 on any failure
// node tools/ci/launch-gates-check.mjs --self-test # a gate row without a check fails; a handoff with the founder's name fails
import { existsSync, readFileSync, mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = process.env.LAUNCH_GATES_ROOT || path.join(HERE, '..', '..');
const GO = 'docs/plans/testnet-go.md';
const PACK = 'docs/plans/launch-pack.md';
const INCOME = 'docs/analysis/income-tiers.md';
function patterns(root) {
const read = f => { try { return readFileSync(path.join(root, f), 'utf8'); } catch { return ''; } };
const lines = [...read('site/forbidden-strings.txt').split('\n'), ...read('tools/ci/forbidden-strings.txt').split('\n')];
return lines.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => new RegExp(l));
}
export function gateRows(text) {
const start = text.indexOf('## Launch gates');
if (start < 0) return null;
const section = text.slice(start);
const rows = [];
let header = null;
for (const line of section.split('\n')) {
if (!line.startsWith('|')) { if (header && rows.length) break; continue; }
const cells = line.split('|').slice(1, -1).map(c => c.trim());
if (!header) { header = cells.map(c => c.toLowerCase()); continue; }
if (cells.every(c => /^-+$/.test(c))) continue;
const row = {}; header.forEach((h, i) => { row[h] = cells[i] || ''; });
rows.push(row);
}
return rows;
}
export function checkGates(root, problems) {
const text = readFileSync(path.join(root, GO), 'utf8');
const rows = gateRows(text);
if (!rows) { problems.push(`${GO}: no "## Launch gates" section`); return; }
if (!rows.length) { problems.push(`${GO}: the Launch gates table has no rows`); return; }
for (const r of rows) {
const id = r.gate || r['#'] || '(no id)';
const check = r.check || '';
if (!check || /^(owed|none|tbd|n\/a)$/i.test(check)) problems.push(`${GO}: gate ${id} has no check`);
for (const m of check.matchAll(/`([^`]+)`/g)) {
const ref = m[1].split(/\s/)[0];
if (/^(tools|docs|site|infra|packaging|proving|app)\//.test(ref) && !existsSync(path.join(root, ref))) problems.push(`${GO}: gate ${id} names ${ref}, which does not exist`);
}
}
return rows.length;
}
export function handoff(root) {
const text = readFileSync(path.join(root, PACK), 'utf8');
const a = text.indexOf('<!-- handoff:start -->'), b = text.indexOf('<!-- handoff:end -->');
if (a < 0 || b < 0 || b < a) return null;
return text.slice(a, b);
}
export function checkText(root, problems) {
const pats = patterns(root);
const h = handoff(root);
if (h === null) { problems.push(`${PACK}: no handoff block between <!-- handoff:start --> and <!-- handoff:end -->`); return; }
const bodies = [[PACK + ' (handoff)', h]];
try { bodies.push([INCOME, readFileSync(path.join(root, INCOME), 'utf8')]); } catch { problems.push(`${INCOME}: missing`); }
for (const [name, body] of bodies) {
body.split('\n').forEach((line, i) => {
if (line.includes('\u2014')) problems.push(`${name}:${i + 1}: an em dash`);
for (const re of pats) if (re.test(line)) problems.push(`${name}:${i + 1}: forbidden pattern ${re.source}`);
});
}
// the eight regulatory sentences, numbered 1 to 8 in order
const nums = [...h.matchAll(/\(8\.3 sentence (\d)[;)]/g)].map(m => Number(m[1]));
for (let k = 1; k <= 8; k++) if (!nums.includes(k)) problems.push(`${PACK} (handoff): regulatory sentence ${k} of future.md 8.3 is missing its "(8.3 sentence ${k})" mark`);
const ordered = nums.filter((v, i, arr) => arr.indexOf(v) === i);
if (ordered.join(',') !== '1,2,3,4,5,6,7,8') problems.push(`${PACK} (handoff): the eight sentences are not in order (${ordered.join(',')})`);
if (!/not legal advice; counsel is engaged/i.test(h)) problems.push(`${PACK} (handoff): the label "not legal advice; counsel is engaged" is missing`);
}
function run(root) {
const problems = [];
const n = checkGates(root, problems);
checkText(root, problems);
return { problems, gates: n || 0 };
}
function selfTest() {
const fx = mkdtempSync(path.join(tmpdir(), 'launch-gates-'));
try {
for (const d of ['docs/plans', 'docs/analysis', 'site', 'tools/ci', 'tools/launch']) mkdirSync(path.join(fx, d), { recursive: true });
writeFileSync(path.join(fx, 'site/forbidden-strings.txt'), 'the project lead\n');
writeFileSync(path.join(fx, 'tools/ci/forbidden-strings.txt'), '/Users/\n');
writeFileSync(path.join(fx, 'tools/launch/x.mjs'), '');
const sentences = Array.from({ length: 8 }, (_, i) => `${i + 1}. sentence (8.3 sentence ${i + 1})`).join('\n');
const good = `# go\n\n## Launch gates\n\n| Gate | What | Check |\n|---|---|---|\n| G1 | a | \`tools/launch/x.mjs\` runs |\n`;
const goodPack = `<!-- handoff:start -->\n${sentences}\nnot legal advice; counsel is engaged\n<!-- handoff:end -->\n`;
writeFileSync(path.join(fx, GO), good); writeFileSync(path.join(fx, PACK), goodPack); writeFileSync(path.join(fx, INCOME), 'clean\n');
let r = run(fx);
if (r.problems.length) throw new Error(`self-test: the clean fixture failed: ${r.problems.join('; ')}`);
// a gate without a check
writeFileSync(path.join(fx, GO), good + '| G2 | b | |\n');
r = run(fx); if (!r.problems.some(p => /G2 has no check/.test(p))) throw new Error('self-test: a gate row without a check passed');
// a check naming a missing script
writeFileSync(path.join(fx, GO), good + '| G3 | c | `tools/launch/missing.mjs` |\n');
r = run(fx); if (!r.problems.some(p => /does not exist/.test(p))) throw new Error('self-test: a check naming a missing script passed');
writeFileSync(path.join(fx, GO), good);
// the founder's name in the handoff, an em dash, a missing sentence
writeFileSync(path.join(fx, PACK), goodPack.replace('1. sentence', '1. the project lead says'));
r = run(fx); if (!r.problems.some(p => /forbidden pattern the project lead/.test(p))) throw new Error('self-test: the founder\'s name in the handoff passed');
writeFileSync(path.join(fx, PACK), goodPack.replace('2. sentence', '2. a \u2014 dash'));
r = run(fx); if (!r.problems.some(p => /em dash/.test(p))) throw new Error('self-test: an em dash in the handoff passed');
writeFileSync(path.join(fx, PACK), goodPack.replace('(8.3 sentence 5)', ''));
r = run(fx); if (!r.problems.some(p => /sentence 5 of future.md 8.3 is missing/.test(p))) throw new Error('self-test: a dropped regulatory sentence passed');
process.stdout.write('self-test passed: a gate without a check, a missing script, the founder\'s name, an em dash and a dropped sentence each fail; the clean fixture passes\n');
return 0;
} finally { rmSync(fx, { recursive: true, force: true }); }
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
if (process.argv.includes('--self-test')) process.exit(selfTest());
const { problems, gates } = run(ROOT);
if (problems.length) { for (const p of problems) process.stderr.write(`${p}\n`); process.exit(1); }
process.stdout.write(`launch gates: ${gates} gate rows, each with its check; the handoff text and the income table carry no forbidden pattern; the eight regulatory sentences are present in order\n`);
}