#!/usr/bin/env node // build-rollup-register.mjs — the state roll-up register builder (STEG 0 wiring). // // WHY THIS EXISTS: cross-repo sessions each keep a LOCAL-ONLY STATE.md whose flush-left // marker lines ("topic: status — prose") are the single source of truth for where each // shared topic stands. This builder assembles those into two DISTINCT outputs, never // conflating marker *durability* with *publicity* (the root cause of AS#5): // // - Output A = rich LOCAL roll-up: full "topic: status — prose" grepped from every // ~/repos/*/STATE.md. For the operator, locally. NEVER published. // - Output B = optional PUBLIC status-token index: "topic: status" ALONE, projected // from A and filtered to repos that opted in via a committed .rollup-carrier. // // Contract source (ratified): ~/.claude/coord/register.md (framework-neutral contract // authored commons-side @ c66ccc3; status vocab @ fe6b998). Catalog owns the builder // wiring + the adversarial axes; commons owns the framework-neutral contract. // // Three builder modes, tolerating absence (no repo is ever forced to commit a carrier): // 1. absent — no carrier -> omitted from B; A covers it fully. // 2. committed — .rollup-carrier -> travels with clone/subtree/public mirror -> B. // 3. private — .rollup-carrier.local (gitignored side-channel) -> A/private only, never B. // // Three gates: // V4 (public-safety) — a carrier line must be exactly "topic: ": no prose, // no em-dash, known token. A violating committed carrier is blocked from B. // V5 (drift-warn) — committed carrier status != STATE marker status -> warn; STATE WINS // (B projects the STATE status, since B is a projection of A). // V6 (marker-loss) — a line that LOOKS like a marker attempt ("topic: ") // but does not parse is REPORTED, never silently dropped. V4/V5 only // ever gated the carrier, so a malformed STATE marker used to vanish // with exit 0 — the loss channel this gate closes. // // Pure-function core is covered by build-rollup-register.test.mjs. Zero npm deps. import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs'; import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; const EMDASH = '—'; // The canonical, closed status vocabulary (register.md "Status-vokabular"). "active" was // folded into in-progress and is invalid; unknown tokens are malformed. export const STATUS_VOCAB = [ 'planned', 'in-progress', 'partial', 'blocked', 'deferred', 'done', 'not-applicable', ]; const TOPIC = '[a-z0-9][a-z0-9-]*'; // --- Output A source: a STATE.md marker line "topic: status — prose" ----------------- // Returns { topic, status, prose } or null if the line is not a well-formed marker // (unknown status tokens are treated as "not a marker", so ordinary prose never matches). export function parseMarkerLine(line) { const m = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)(?:\\s+${EMDASH}\\s+(.*))?\\s*$`)); if (!m) return null; const [, topic, status, prose] = m; if (!STATUS_VOCAB.includes(status)) return null; return { topic, status, prose: prose ? prose.trim() : '' }; } // --- V6 marker-loss gate -------------------------------------------------------------- // parseMarkerLine collapses two DIFFERENT facts into one null: "this is not a marker" and // "this is a marker that failed to parse". Callers cannot tell them apart, so a malformed // marker disappeared with no warning and exit 0. A marker ATTEMPT is a line whose topic and // KNOWN status token are both present — an unknown token stays "not a marker" (that is the // existing, deliberate rule), which is what keeps ordinary prose out of the loss channel. // Deliberately a warning, not a failure: a typo in another repo's LOCAL state file must be // visible, but it is not this builder's business to fail on. const MARKER_ATTEMPT = new RegExp(`^(${TOPIC}):\\s+(\\S+)`); // Returns { markers, losses:[{ lineNo, line }] } for one STATE.md text. export function extractMarkers(stateText) { const markers = []; const losses = []; stateText.split('\n').forEach((line, i) => { const m = parseMarkerLine(line); if (m) { markers.push(m); return; } const attempt = line.match(MARKER_ATTEMPT); if (attempt && STATUS_VOCAB.includes(attempt[2])) losses.push({ lineNo: i + 1, line }); }); return { markers, losses }; } // --- Output B source: a bare carrier line "topic: status" (token ALONE) --------------- // Returns { topic, status } or null. Any prose, em-dash, or unknown token -> null. export function parseCarrierLine(line) { const m = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)\\s*$`)); if (!m) return null; const [, topic, status] = m; if (!STATUS_VOCAB.includes(status)) return null; return { topic, status }; } // --- V4 public-safety gate ------------------------------------------------------------ // Validate a whole .rollup-carrier file. Blank lines are tolerated. Every other line MUST // be an exact bare carrier line; otherwise it is a violation with a classified reason: // em-dash | prose-after-token | unknown-status | malformed // Returns { ok, entries, violations:[{ lineNo, line, reason }] }. export function validateCarrier(text) { const entries = []; const violations = []; text.split('\n').forEach((line, i) => { const lineNo = i + 1; if (line.trim() === '') return; // blank: tolerated, not a violation if (line.includes(EMDASH)) { violations.push({ lineNo, line, reason: 'em-dash' }); return; } const entry = parseCarrierLine(line); if (entry) { entries.push(entry); return; } // Not a clean carrier line — classify why so the operator can fix it precisely. const shape = line.match(new RegExp(`^(${TOPIC}):\\s+(\\S+)(\\s+.*)?$`)); if (shape) { const status = shape[2]; const trailing = shape[3]; if (!STATUS_VOCAB.includes(status)) violations.push({ lineNo, line, reason: 'unknown-status' }); else if (trailing && trailing.trim() !== '') violations.push({ lineNo, line, reason: 'prose-after-token' }); else violations.push({ lineNo, line, reason: 'malformed' }); } else { violations.push({ lineNo, line, reason: 'malformed' }); } }); return { ok: violations.length === 0, entries, violations }; } // --- V5 drift-warn -------------------------------------------------------------------- // Compare a repo's STATE markers against its carrier entries. Only shared topics are // compared; a disagreement yields { topic, stateStatus, carrierStatus }. STATE wins. export function computeDrift(markers, carrierEntries) { const stateByTopic = new Map(markers.map((m) => [m.topic, m.status])); const drift = []; for (const c of carrierEntries) { if (stateByTopic.has(c.topic) && stateByTopic.get(c.topic) !== c.status) { drift.push({ topic: c.topic, stateStatus: stateByTopic.get(c.topic), carrierStatus: c.status }); } } return drift; } // --- Orchestrator --------------------------------------------------------------------- // repos: [{ name, statePath, markers:[{topic,status,prose}], losses, carrier:string|null, carrierMode }] // Returns: // outputA [{ repo, topic, status, prose }] — LOCAL roll-up, every repo/mode. // outputB ["topic: status", ...] (sorted) — PUBLIC index; committed + V4-clean only. // drift [{ repo, topic, stateStatus, carrierStatus }] // violations [{ repo, lineNo, line, reason }] // markerLosses[{ repo, statePath, lineNo, line }] — V6: marker attempts that did not parse. export function buildRegister({ repos }) { const outputA = []; const outputBLines = []; const drift = []; const violations = []; const markerLosses = []; for (const r of repos) { for (const m of r.markers) { outputA.push({ repo: r.name, topic: m.topic, status: m.status, prose: m.prose ?? '' }); } for (const l of r.losses ?? []) { markerLosses.push({ repo: r.name, statePath: r.statePath ?? null, ...l }); } // Only mode "committed" reaches the public index B. if (r.carrierMode !== 'committed' || r.carrier == null) continue; const v = validateCarrier(r.carrier); if (!v.ok) { for (const viol of v.violations) violations.push({ repo: r.name, ...viol }); continue; // a V4-violating carrier must not pollute the public index } for (const d of computeDrift(r.markers, v.entries)) drift.push({ repo: r.name, ...d }); // B is a projection of A: STATE status is authoritative (V5 — STATE wins on drift). const stateByTopic = new Map(r.markers.map((m) => [m.topic, m.status])); for (const e of v.entries) { const status = stateByTopic.has(e.topic) ? stateByTopic.get(e.topic) : e.status; outputBLines.push(`${e.topic}: ${status}`); } } outputBLines.sort(); return { outputA, outputB: outputBLines, drift, violations, markerLosses }; } // --- Repo discovery: depth 1 + depth 2 under polyrepo containers ---------------------- // Mirrors board.sh:63-89 EXACTLY, and deliberately so: a directory that is itself a git // repo is one repo and is NOT descended into; a directory that is not a repo but holds // git repos is a polyrepo container (the plugin marketplace) contributing its children, // never itself. A depth-1-only glob missed 5 markers across 11 depth-2 STATE.md files. // // Identity is the RAW BASENAME (board.sh:100), not "parent/child", so this builder, board.sh // and the coord mailboxes (~/.claude/coord//) all name the same repo the same way. // Two names for one repo is the AS#5 defect class. Measured today: 0 collisions across 25 // state-bearing directories — a fact, not a guarantee, hence the collision gate below. // // Not descending into a git repo also makes the known nested-name trap // (claude-code-100x/claude-code-100x, both git repos) structurally unreachable rather than // merely absent. // // "Is a repo" tests `.git` for EXISTENCE, not directory-ness: a worktree or submodule has // `.git` as a FILE. A plain `git worktree add ~/repos/feature-x` lands a depth-1 sibling // that can carry its own STATE.md, and a directory test dropped it SILENTLY — no warning, // no count, indistinguishable from a repo with nothing to say. This was pinned rather than // fixed one-sidedly because diverging would break the very same-name-from-both-readers // invariant this function exists to hold. board.sh's side is in place — MEASURED, not // reported: its Discovery block tests `-e`, and has since board.sh was introduced // (repo-mailbox 61e224c, first released v0.9.0). This is the other half. The two move // together or not at all — change one, change the other. function isGitRepo(dir) { const dotGit = join(dir, '.git'); return existsSync(dotGit); } function subdirs(dir) { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => join(dir, e.name)); } catch { return []; } } // Read one repo dir into a register record, or null when it contributes nothing. function readRepo(repoDir) { const statePath = join(repoDir, 'STATE.md'); if (!existsSync(statePath) || !statSync(statePath).isFile()) return null; const { markers, losses } = extractMarkers(readFileSync(statePath, 'utf8')); // A repo with only LOSSES still contributes: dropping it here would re-open the exact // silent channel V6 closes. if (markers.length === 0 && losses.length === 0) return null; const committed = join(repoDir, '.rollup-carrier'); const priv = join(repoDir, '.rollup-carrier.local'); let carrier = null; let carrierMode = 'absent'; if (existsSync(committed)) { carrier = readFileSync(committed, 'utf8'); carrierMode = 'committed'; } else if (existsSync(priv)) { carrier = readFileSync(priv, 'utf8'); carrierMode = 'private'; } return { name: basename(repoDir), statePath, markers, losses, carrier, carrierMode }; } export function discoverRepos(reposRoot) { const repoDirs = []; for (const entry of subdirs(reposRoot)) { if (isGitRepo(entry)) { repoDirs.push(entry); continue; } for (const child of subdirs(entry)) { if (isGitRepo(child)) repoDirs.push(child); } } const repos = []; const seen = new Map(); for (const dir of repoDirs) { const r = readRepo(dir); if (!r) continue; // contributes nothing -> cannot collide with anything const prior = seen.get(r.name); // Fail HIGH: a silent pick-one would make the register quietly wrong about which repo // a marker came from, which is worse than not building at all. Gated on CONTRIBUTING // repos only — two same-named directories where just one bears a STATE.md are not // ambiguous, so they must not stop the build. if (prior) { throw new Error( `repo name collision: "${r.name}" resolves to two STATE-bearing directories ` + `(${prior} and ${dir}). The register cannot attribute markers unambiguously.`, ); } seen.set(r.name, dir); repos.push(r); } return repos; } // --- CLI shell (thin) ----------------------------------------------------------------- // Discovers repos for Output A, detects carrier mode per repo, prints both outputs with // clear LOCAL/PUBLIC framing plus drift + violation + marker-loss notices. The operator // redirects; the builder itself commits nothing. const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isMain) { const reposRoot = process.argv[2] || join(homedir(), 'repos'); let repos; try { repos = discoverRepos(reposRoot); } catch (err) { // A name collision makes every marker's attribution suspect, so emit nothing at all. process.stderr.write(`FATAL ${err.message}\n`); process.exit(2); } const { outputA, outputB, drift, violations, markerLosses } = buildRegister({ repos }); const out = []; out.push(`# State roll-up register — source: ${reposRoot}/*/STATE.md + /*/STATE.md`); // "discovered", not "with markers": a repo carrying only V6 losses is discovered and // reported, but contributes nothing to A. out.push(`# repos discovered: ${repos.length} (` + `${repos.filter((r) => r.carrierMode === 'committed').length} committed, ` + `${repos.filter((r) => r.carrierMode === 'private').length} private, ` + `${repos.filter((r) => r.carrierMode === 'absent').length} absent)`); out.push(''); out.push('## Output A — rich LOCAL roll-up (LOCAL-ONLY, never publish)'); for (const e of outputA) { out.push(`${e.repo.padEnd(28)} ${e.topic}: ${e.status}${e.prose ? ` ${EMDASH} ${e.prose}` : ''}`); } out.push(''); out.push(`## Output B — public status-token index (${outputB.length} line(s); publishable)`); for (const line of outputB) out.push(line); out.push(''); const notices = []; for (const v of violations) { notices.push(`V4 VIOLATION ${v.repo} .rollup-carrier:${v.lineNo} [${v.reason}] ${v.line.trim()}`); } for (const d of drift) { notices.push(`V5 DRIFT-WARN ${d.repo} "${d.topic}": STATE=${d.stateStatus} != carrier=${d.carrierStatus} (STATE wins in B)`); } for (const l of markerLosses) { notices.push(`V6 MARKER-LOSS ${l.statePath ?? l.repo}:${l.lineNo} did not parse as a marker: ${l.line.trim()}`); } if (notices.length) { process.stderr.write(`${notices.join('\n')}\n`); } process.stdout.write(`${out.join('\n')}\n`); process.exit(violations.length === 0 ? 0 : 1); }