diff --git a/.gitattributes b/.gitattributes index 842c2776d7..9137892c8b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,8 +6,23 @@ # ═════════════════════════════════════════════════════════════════════════════════════════ # Experience logs are append-only JSONL — concatenate both sides on merge -.trinity/experience/*.jsonl merge=append-log +# `append-log` is not a built-in driver: it needs a merge.append-log.driver +# entry in .git/config, and nothing in this checkout installs one — so in +# every fresh clone this line does nothing and the file conflicts normally. +# For append-only JSONL, `union` is that behaviour and is built in. +.trinity/experience/*.jsonl merge=union -# NOW.md: canonical location is docs/NOW.md -# Root NOW.md is a symlink to docs/NOW.md — prefer incoming version -NOW.md merge=theirs +# NOW.md: canonical location is docs/NOW.md. The root file is a symlink, so +# git merges the link target, not the text — and `theirs` is not a built-in +# driver either (same missing .git/config entry as append-log had). Point the +# symlink's rule at the same built-in the real file uses; if the symlink is +# ever replaced by a real file, this keeps working. +NOW.md merge=union + +# The rule above names the SYMLINK, not the file that actually conflicts, and +# `theirs` is not a built-in driver — it needs a per-clone .git/config entry +# that a fresh checkout does not have. So docs/NOW.md conflicted on every +# branch (seven times in one campaign), each resolved identically by hand: +# keep both entries. `union` is built in, so it works in every clone with no +# setup, and it is exactly that resolution. +docs/NOW.md merge=union diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1213c56fce..4d474e1b8a 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -10,12 +10,17 @@ cd "$ROOT" # ===== NOW.md Gate ===== bash "$ROOT/scripts/tri" check-now -if ! git diff --cached --name-only | grep -q '^NOW.md$'; then - if git diff --name-only | grep -q '^NOW.md$'; then +# The canonical file is docs/NOW.md — this gate used to match '^NOW.md$' +# only, so every commit that correctly updated docs/NOW.md still printed the +# warning, and the root file (a regular 390-line file, not the symlink the +# .gitattributes comment describes) has not been touched since 2026-08-09. +# Accept either path; the root copy's fate is the owner's call, not a hook's. +if ! git diff --cached --name-only | grep -qE '^(docs/)?NOW\.md$'; then + if git diff --name-only | grep -qE '^(docs/)?NOW\.md$'; then echo "" echo "⚠️ WARNING: NOW.md is modified but NOT staged." - echo " Run: git add NOW.md" - echo " Or: stage and commit NOW.md together with your changes." + echo " Run: git add docs/NOW.md" + echo " Or: stage and commit it together with your changes." echo "" fi fi diff --git a/.tri/environments.json b/.tri/environments.json new file mode 100644 index 0000000000..06e37d5884 --- /dev/null +++ b/.tri/environments.json @@ -0,0 +1,25 @@ +{ + "_comment": "Which environments each capability claim rests on. Checked by `tri fleet asof --from .tri/environments.json`. A claim listed here can be re-verified in one command instead of being repeated on faith.", + "claims": [ + { + "name": "t27.ai publishes the project's writing", + "urls": ["https://t27.ai", "https://gHashTag.github.io"] + }, + { + "name": "the hiking site is serving", + "urls": ["https://floripahikegpro.vercel.app"] + }, + { + "name": "GitHub hosts the repositories this work is pushed to", + "urls": ["https://github.com/gHashTag/t27", "https://github.com/gHashTag/trinity-fpga"] + }, + { + "name": "the FPGA fleet can be flashed and measured from software", + "needs_hardware": true + }, + { + "name": "the 3-board inference cluster can be re-run", + "needs_hardware": true + } + ] +} diff --git a/cli/tri/src/fleet.rs b/cli/tri/src/fleet.rs new file mode 100644 index 0000000000..dbf7b46933 --- /dev/null +++ b/cli/tri/src/fleet.rs @@ -0,0 +1,321 @@ +//! `tri fleet` — is the hardware this plan assumes actually attached? +//! +//! Written after an audit found three present-tense capability claims in the +//! project's own notes — "3-board inference cluster PROVEN", "all three flash +//! from software without replugging", "on-chip training PROVEN" — while not a +//! single board was on the bus. Each was true when measured. None was true +//! that day. +//! +//! A measurement stays true because it happened; a capability quietly becomes +//! false when the environment regresses, and nothing announces it. The notes +//! even said "a configured fleet is a perishable measurement" — the instinct +//! was recorded and never turned into a check. This is that check. +//! +//! It refuses to guess: an empty bus is reported as an empty bus, with the +//! sentence to send the owner, not as a problem to code around. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Subcommand)] +pub enum FleetCmd { + /// Scan the USB bus and say plainly what hardware is present. + Scan { + /// How many boards the plan expects. Non-zero exit if fewer are found. + #[arg(long)] + expect: Option, + }, + /// Check the environments a claim depends on before repeating the claim. + /// + /// The bus is one environment; a deployed site is another. Both go stale + /// the same way — the note says "works" because it worked, and nothing + /// announces the regression. This checks each named URL and the bus, and + /// reports which capability claims are currently unverifiable. + Asof { + /// URLs the claim depends on, repeatable. Checked with a HEAD request. + #[arg(long = "url")] + urls: Vec, + /// Also require hardware on the bus. + #[arg(long)] + needs_hardware: bool, + /// Read the claims from a declaration instead of the command line. + /// + /// Remembering which URLs back which claim is exactly the step that + /// gets skipped, so the claims live in the repository next to the + /// code they describe. Format: + /// + /// {"claims":[{"name":"...","urls":["..."],"needs_hardware":false}]} + #[arg(long)] + from: Option, + }, +} + +#[derive(serde::Deserialize)] +struct Claim { + name: String, + #[serde(default)] + urls: Vec, + #[serde(default)] + needs_hardware: bool, +} + +#[derive(serde::Deserialize)] +struct Claims { + claims: Vec, +} + +pub fn run(cmd: &FleetCmd) -> Result<()> { + match cmd { + FleetCmd::Scan { expect } => scan(*expect), + FleetCmd::Asof { + urls, + needs_hardware, + from, + } => match from { + Some(path) => asof_declared(path), + None => asof(urls, *needs_hardware), + }, + } +} + +/// Check every claim in a declaration and report per claim, because "is the +/// project fine" has no answer — each claim rests on its own environments and +/// they fail independently. +fn asof_declared(path: &PathBuf) -> Result<()> { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let decl: Claims = + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + + let mut stale: Vec = Vec::new(); + for c in &decl.claims { + let mut missing: Vec = Vec::new(); + for u in &c.urls { + let (ok, code) = head(u); + if !ok { + missing.push(format!("{u} (HTTP {code})")); + } + } + if c.needs_hardware { + match probe_usb() { + Ok(b) if !b.is_empty() => {} + Ok(_) => missing.push("hardware (bus empty)".to_string()), + Err(_) => missing.push("hardware (cannot tell)".to_string()), + } + } + if missing.is_empty() { + println!("SAYABLE {}", c.name); + } else { + println!("STALE {}", c.name); + for m in &missing { + println!(" needs {m}"); + } + stale.push(c.name.clone()); + } + } + + println!(); + if stale.is_empty() { + println!( + "VERDICT: all {} claim(s) rest on reachable environments.", + decl.claims.len() + ); + return Ok(()); + } + println!( + "VERDICT: {} of {} claim(s) may NOT be stated in the present tense today.", + stale.len(), + decl.claims.len() + ); + anyhow::bail!("{} stale claim(s)", stale.len()) +} + +/// HEAD one URL. A timeout is a failure to verify, not a failure of the site — +/// the two are reported differently because only one of them is the owner's +/// problem. +fn head(url: &str) -> (bool, String) { + let out = Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-m", + "15", + "-L", + "-I", + url, + ]) + .output(); + match out { + Ok(o) => { + let code = String::from_utf8_lossy(&o.stdout).trim().to_string(); + let ok = code.starts_with('2') || code.starts_with('3'); + (ok, code) + } + Err(e) => (false, format!("curl failed: {e}")), + } +} + +fn asof(urls: &[String], needs_hardware: bool) -> Result<()> { + let mut unverifiable: Vec = Vec::new(); + + for u in urls { + let (ok, code) = head(u); + println!("{:<7} {u}", if ok { "live" } else { "DOWN" }); + if !ok { + unverifiable.push(format!("{u} (HTTP {code})")); + } + } + + if needs_hardware { + match probe_usb() { + Ok(b) if !b.is_empty() => println!("{:<7} {} USB bridge(s)", "live", b.len()), + Ok(_) => { + println!("{:<7} no board on the bus", "DOWN"); + unverifiable.push("hardware (bus empty)".to_string()); + } + Err(e) => { + println!("{:<7} {e}", "UNKNOWN"); + unverifiable.push("hardware (cannot tell)".to_string()); + } + } + } + + println!(); + if unverifiable.is_empty() { + println!("VERDICT: every environment this claim depends on is reachable."); + println!("The claim can be repeated in the present tense today."); + return Ok(()); + } + + println!( + "VERDICT: {} environment(s) unreachable:", + unverifiable.len() + ); + for u in &unverifiable { + println!(" {u}"); + } + println!(); + println!("Any note asserting this capability in the present tense is a MEASUREMENT"); + println!("of the past, not a capability of today. Re-verify before repeating it."); + anyhow::bail!("{} environment(s) unverifiable", unverifiable.len()) +} + +/// One JTAG/UART bridge as the bus reports it. +struct Bridge { + kind: String, + serial: Option, +} + +/// Read the USB tree. macOS only — on anything else this says so rather than +/// reporting an empty fleet, because "no boards" and "cannot tell" are +/// different answers and only one of them is safe to act on. +fn probe_usb() -> Result> { + if !cfg!(target_os = "macos") { + anyhow::bail!("bus probe is implemented for macOS only; cannot tell what is attached"); + } + let out = Command::new("ioreg") + .args(["-p", "IOUSB", "-l", "-w0"]) + .output() + .context("ioreg is not available")?; + let text = String::from_utf8_lossy(&out.stdout); + + let mut found = Vec::new(); + let mut pending: Option = None; + for line in text.lines() { + let l = line.trim(); + // Device nodes appear as `+-o @`; the serial follows in + // that node's property block a few lines later. + if l.starts_with("+-o") { + let name = l.trim_start_matches("+-o").trim(); + let lower = name.to_ascii_lowercase(); + pending = if lower.contains("ft232") + || lower.contains("ftdi") + || lower.contains("usb serial") + || lower.contains("jtag") + { + Some(name.split('@').next().unwrap_or(name).trim().to_string()) + } else { + None + }; + if let Some(kind) = pending.clone() { + found.push(Bridge { kind, serial: None }); + } + } else if pending.is_some() && l.contains("\"USB Serial Number\"") { + if let Some(v) = l.split('=').nth(1) { + if let Some(b) = found.last_mut() { + b.serial = Some(v.trim().trim_matches('"').to_string()); + } + } + pending = None; + } + } + Ok(found) +} + +/// Serial device nodes, which is what a UART console actually needs. Bluetooth +/// and the debug console are always present and are not hardware. +fn probe_tty() -> Vec { + let mut nodes = Vec::new(); + if let Ok(dir) = std::fs::read_dir("/dev") { + for e in dir.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if (name.starts_with("cu.") || name.starts_with("tty.")) + && !name.contains("Bluetooth") + && !name.contains("debug-console") + { + nodes.push(name); + } + } + } + nodes.sort(); + nodes +} + +fn scan(expect: Option) -> Result<()> { + let bridges = probe_usb()?; + let ttys = probe_tty(); + + println!("USB JTAG/UART bridges: {}", bridges.len()); + for b in &bridges { + match &b.serial { + Some(s) => println!(" {} (serial {s})", b.kind), + None => println!(" {}", b.kind), + } + } + println!("serial device nodes: {}", ttys.len()); + for t in &ttys { + println!(" /dev/{t}"); + } + println!(); + + if bridges.is_empty() && ttys.is_empty() { + println!("VERDICT: no hardware attached."); + println!(); + println!("Any 'proven on hardware' note in this project is a MEASUREMENT of the past,"); + println!("not a capability of today. Do not write code for a board that is not there,"); + println!("and do not report readiness to flash. Tell the owner instead:"); + println!(); + println!(" \"No board is on the bus — the fleet needs to be plugged in before"); + println!(" anything hardware-side can run. This needs hands, not code.\""); + } else { + println!( + "VERDICT: {} bridge(s), {} serial node(s) present.", + bridges.len(), + ttys.len() + ); + } + + if let Some(n) = expect { + if bridges.len() < n { + anyhow::bail!( + "expected {n} board(s), found {} — refusing to report a fleet that is not there", + bridges.len() + ); + } + } + Ok(()) +} diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index 72f11fc5a3..559835084a 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -8,15 +8,17 @@ use std::path::{Path, PathBuf}; use std::process::Command; mod depin; +mod fleet; mod fpga; mod gates; mod hooks; mod mutate; +mod nownote; mod prcheck; -mod sweep; -mod synth; mod red; mod rtl; +mod sweep; +mod synth; #[derive(Parser)] #[command(name = "tri", about = "PHI LOOP CLI wrapper")] @@ -70,6 +72,16 @@ enum Commands { #[command(subcommand)] action: mutate::MutateCmd, }, + /// Prepend a docs/NOW.md entry without hand-writing the frame. + Now { + #[command(subcommand)] + action: nownote::NowCmd, + }, + /// Is the hardware this plan assumes actually attached? + Fleet { + #[command(subcommand)] + action: fleet::FleetCmd, + }, /// Is this pull request actually safe to merge? Pr { #[command(subcommand)] @@ -699,6 +711,8 @@ fn main() -> Result<()> { Commands::Serve { addr } => cmd_serve(addr)?, Commands::Fpga { action } => fpga::run(action)?, Commands::Mutate { action } => mutate::run(action)?, + Commands::Now { action } => nownote::run(action)?, + Commands::Fleet { action } => fleet::run(action)?, Commands::Pr { action } => prcheck::run(action)?, Commands::Sweep { action } => sweep::run(action)?, Commands::Synth { action } => synth::run(action)?, diff --git a/cli/tri/src/nownote.rs b/cli/tri/src/nownote.rs new file mode 100644 index 0000000000..af3f3a84f4 --- /dev/null +++ b/cli/tri/src/nownote.rs @@ -0,0 +1,85 @@ +//! `tri now` — prepend a docs/NOW.md entry without hand-writing the frame. +//! +//! Every pull request in this repository must touch docs/NOW.md (the +//! check-now-freshness gate), and the entry format is rigid enough that +//! writing it by hand invites drift: a forgotten date, a heading that does +//! not match the section, a missing issue reference. One forgotten entry +//! cost a full gate round trip. This stamps the frame; the caller supplies +//! only the content. + +use anyhow::{Context, Result}; +use clap::Subcommand; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum NowCmd { + /// Prepend an entry to docs/NOW.md: title, bullets, optional issue ref. + Add { + /// Entry title, used for both the page heading and the section. + title: String, + /// Bullet lines, repeatable. At least one is required — an entry + /// with no content is exactly the vacuous touch the gate invites. + /// + /// `allow_hyphen_values` because entry text legitimately starts with a + /// flag name: writing this command's own NOW entry failed on the + /// bullet describing `--expect`, which clap read as an argument. + #[arg(long = "bullet", required = true, allow_hyphen_values = true)] + bullets: Vec, + /// Issue number for the section's "(Closes #N)" suffix. + #[arg(long)] + closes: Option, + }, +} + +pub fn run(cmd: &NowCmd) -> Result<()> { + match cmd { + NowCmd::Add { + title, + bullets, + closes, + } => add(title, bullets, *closes), + } +} + +fn repo_root() -> Result { + let out = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("git is not installed or not on PATH")?; + if !out.status.success() { + anyhow::bail!("not inside a git repository"); + } + Ok(PathBuf::from( + String::from_utf8_lossy(&out.stdout).trim().to_string(), + )) +} + +/// Today's date from the local clock, YYYY-MM-DD, with no chrono dependency: +/// `git` is already a hard requirement of this command and its author date +/// formatting is stable. +fn today() -> Result { + let out = std::process::Command::new("date") + .args(["+%Y-%m-%d"]) + .output() + .context("date is not available")?; + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +fn add(title: &str, bullets: &[String], closes: Option) -> Result<()> { + let path = repo_root()?.join("docs").join("NOW.md"); + let old = std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let date = today()?; + let suffix = match closes { + Some(n) => format!(" (Closes #{n})"), + None => String::new(), + }; + let mut entry = + format!("# NOW -- {title} ({date})\n\nLast updated: {date}\n\n## {title}{suffix}\n\n"); + for b in bullets { + entry.push_str(&format!("- {b}\n")); + } + entry.push('\n'); + std::fs::write(&path, entry + &old).with_context(|| format!("write {}", path.display()))?; + println!("prepended NOW entry: {title} ({date})"); + Ok(()) +} diff --git a/cli/tri/src/prcheck.rs b/cli/tri/src/prcheck.rs index b5ce658093..58d669871d 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -22,6 +22,36 @@ use std::process::Command; #[derive(Subcommand)] pub enum PrCmd { /// Classify every failing check and say plainly whether it is safe to merge. + /// Did this pull request's content actually reach the default branch? + /// + /// "merged" and "closed" both read as success in a pull-request list. A + /// stack taught this the expensive way: the base squash-merged, its + /// branch was deleted, the pull request stacked on it auto-closed, and + /// four commits reached nothing while the list looked fine. Only a + /// content probe distinguishes the two. + Landed { + /// Pull request number. + number: u64, + /// owner/repo. Defaults to the repository in the current directory. + #[arg(long)] + repo: Option, + /// A string the pull request introduced. Repeatable. Each is looked + /// for in the default branch's copy of the files the PR touched. + /// + /// Choose something the change ALONE introduced, and copy it exactly. + /// Three real misses on first use, all of them the probe's fault and + /// not the tool's: `0x3E00` was already in the codec's own source (a + /// probe the repository can satisfy without the change proves + /// nothing); a probe spanning a line break failed until whitespace + /// was flattened on both sides; and one differed only in the case of + /// its first letter. Case is text and stays significant; line wrapping + /// is formatting and does not. A fourth appeared during a sweep of + /// older merges: probing pull request N with wording a LATER pull + /// request rewrote. Probe with the string as that pull request + /// introduced it, not as the file reads today. + #[arg(long = "probe", required = true)] + probes: Vec, + }, Ready { /// Pull request number. number: u64, @@ -59,7 +89,161 @@ pub fn run(cmd: &PrCmd) -> Result<()> { poll, merge, } => ready(*number, repo.as_deref(), *baseline, *wait, *poll, *merge), + PrCmd::Landed { + number, + repo, + probes, + } => landed(*number, repo.as_deref(), probes), + } +} + +/// Check that what the pull request introduced is present in the default +/// branch, file by file. Status is not content: a merged pull request whose +/// stack-mate was auto-closed leaves a list that reads as success. +fn landed(n: u64, repo: Option<&str>, probes: &[String]) -> Result<()> { + let repo = match repo { + Some(r) => r.to_string(), + None => gh(&[ + "repo", + "view", + "--json", + "nameWithOwner", + "--jq", + ".nameWithOwner", + ])?, + }; + let merged = gh(&["api", &format!("repos/{repo}/pulls/{n}"), "--jq", ".merged"])?; + let branch = gh(&["api", &format!("repos/{repo}"), "--jq", ".default_branch"])?; + let branch = branch.trim(); + + println!("{repo}#{n} — merged: {merged}"); + + let files = gh(&[ + "api", + &format!("repos/{repo}/pulls/{n}/files?per_page=100"), + "--paginate", + "--jq", + ".[].filename", + ])?; + let files: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect(); + println!("files the pull request touched: {}", files.len()); + + // Fetch each file once from the default branch; a file the PR deleted or + // that never landed simply is not there, which is itself an answer. + let mut corpus = String::new(); + let mut missing_files = 0usize; + for f in &files { + match gh(&[ + "api", + &format!("repos/{repo}/contents/{f}?ref={branch}"), + "--jq", + ".content", + ]) { + Ok(b64) => { + let cleaned: String = b64.chars().filter(|c| !c.is_whitespace()).collect(); + if let Ok(bytes) = base64_decode(&cleaned) { + corpus.push_str(&String::from_utf8_lossy(&bytes)); + corpus.push('\n'); + } + } + Err(_) => missing_files += 1, + } + } + if missing_files > 0 { + println!(" ({missing_files} of them are not on {branch} at all)"); + } + + // Prose gets re-wrapped, so a probe that spans a line break would fail + // against text that is actually present -- which happened on the first + // real use. Compare with whitespace flattened on both sides. + let flat_corpus = flatten_ws(&corpus); + + let mut absent = Vec::new(); + for p in probes { + if flat_corpus.contains(&flatten_ws(p)) { + println!(" PRESENT {p}"); + } else { + println!(" ABSENT {p}"); + absent.push(p.clone()); + } + } + println!(); + if absent.is_empty() { + println!("VERDICT: the content landed on {branch}."); + return Ok(()); + } + println!("VERDICT: {} probe(s) are NOT on {branch}.", absent.len()); + println!("A pull request can read as merged while its content reached nothing —"); + println!("that is what a squash-merged stack does to whatever sat on top of it."); + anyhow::bail!("{} probe(s) absent from {branch}", absent.len()) +} + +/// Confirm from the API — not from an exit code — that the pull request is +/// merged and its merge commit is reachable from the default branch. Returns +/// the short merge sha so the caller can print what it verified. +fn confirm_merged(repo: &str, n: u64) -> Result { + let merged = gh(&["api", &format!("repos/{repo}/pulls/{n}"), "--jq", ".merged"])?; + if merged.trim() != "true" { + anyhow::bail!("the API still reports merged={}", merged.trim()); + } + let sha = gh(&[ + "api", + &format!("repos/{repo}/pulls/{n}"), + "--jq", + ".merge_commit_sha", + ])?; + let sha = sha.trim().to_string(); + if sha.is_empty() || sha == "null" { + anyhow::bail!("merged=true but there is no merge commit sha"); + } + let branch = gh(&["api", &format!("repos/{repo}"), "--jq", ".default_branch"])?; + let branch = branch.trim(); + // "identical" or "behind" both mean the commit is contained in the branch. + let status = gh(&[ + "api", + &format!("repos/{repo}/compare/{branch}...{sha}"), + "--jq", + ".status", + ])?; + let status = status.trim(); + if status != "identical" && status != "behind" { + anyhow::bail!( + "merge commit {} is {status} relative to {branch}", + &sha[..7.min(sha.len())] + ); } + Ok(sha[..7.min(sha.len())].to_string()) +} + +/// Collapse every run of whitespace to a single space, so a probe matches +/// text that has since been re-wrapped. +fn flatten_ws(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Minimal base64 decode: the GitHub contents API returns file bodies this +/// way and pulling a crate in for one call is not worth the dependency. +fn base64_decode(s: &str) -> Result> { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let mut buf = 0u32; + let mut bits = 0u32; + for c in s.bytes() { + if c == b'=' { + break; + } + let v = match T.iter().position(|&t| t == c) { + Some(v) => v as u32, + None => continue, + }; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + Ok(out) } fn gh(args: &[&str]) -> Result { @@ -108,11 +292,19 @@ fn failures_of(repo: &str, n: u64) -> Result> { /// merged while ten checks were still running. So this returns the completed /// count too, and the caller waits for it to stop growing. fn in_flight(repo: &str, n: u64) -> Result<(usize, usize)> { - let sha = gh(&["api", &format!("repos/{repo}/pulls/{n}"), "--jq", ".head.sha"])?; + let sha = gh(&[ + "api", + &format!("repos/{repo}/pulls/{n}"), + "--jq", + ".head.sha", + ])?; // Two plain queries rather than one clever @tsv: the combined form failed // with "expected an object but got: array" the first time it ran, and a // wait loop that errors out is worse than no wait loop. - let path = format!("repos/{repo}/commits/{}/check-runs?per_page=100", sha.trim()); + let path = format!( + "repos/{repo}/commits/{}/check-runs?per_page=100", + sha.trim() + ); let pending: usize = gh(&[ "api", &path, @@ -129,12 +321,33 @@ fn in_flight(repo: &str, n: u64) -> Result<(usize, usize)> { Ok((pending, total)) } -fn ready(n: u64, repo: Option<&str>, baseline: usize, wait: bool, poll: u64, merge: bool) -> Result<()> { +fn ready( + n: u64, + repo: Option<&str>, + baseline: usize, + wait: bool, + poll: u64, + merge: bool, +) -> Result<()> { let repo = match repo { Some(r) => r.to_string(), - None => gh(&["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])?, + None => gh(&[ + "repo", + "view", + "--json", + "nameWithOwner", + "--jq", + ".nameWithOwner", + ])?, }; + // Say which pull request this is BEFORE the wait loop, not after it. + // Every "waiting: N of M" line used to carry no identity, so two gates + // logging to one file produced a transcript where one PR's verdict read + // as the other's -- diagnosing through a channel shared by two sources, + // which is the error this project's own doctrine is named after. + println!("{repo}#{n} — gate started"); + // Anything still running makes the answer provisional, so say so rather // than reporting a verdict on a partial list. let mut pending = in_flight(&repo, n)?.0; @@ -166,7 +379,7 @@ fn ready(n: u64, repo: Option<&str>, baseline: usize, wait: bool, poll: u64, mer }; if p > 0 { quiet = 0; - println!(" waiting: {p} of {total} check(s) still running"); + println!(" [{repo}#{n}] waiting: {p} of {total} check(s) still running"); } else if total == 0 { // An empty list is not "finished" -- it is "not started". Give // it a few rounds before believing it. @@ -195,22 +408,42 @@ fn ready(n: u64, repo: Option<&str>, baseline: usize, wait: bool, poll: u64, mer // few merged pull requests. A check red in both places is the repository's // problem, not this change's. let branch = gh(&["api", &format!("repos/{repo}"), "--jq", ".default_branch"])?; - let head = gh(&[ - "api", - &format!("repos/{repo}/commits/{branch}"), - "--jq", - ".sha", - ])?; + // The default branch's HEAD is not the default branch. A check that did + // not run on HEAD -- a docs-only commit, a path filter -- shows neither + // green nor red there, and reading HEAD alone once made a broken build + // look "green on master" because the check-run was attached to an older + // commit. So walk the last few default-branch commits and score each check + // by the MOST RECENT commit on which it actually ran. let mut seen: BTreeMap = BTreeMap::new(); - for name in gh(&[ + let recent = gh(&[ "api", - &format!("repos/{repo}/commits/{}/check-runs?per_page=100", head.trim()), + &format!("repos/{repo}/commits?sha={branch}&per_page=15"), "--jq", - r#".check_runs[]|select(.conclusion=="failure")|.name"#, - ])? - .lines() - { - *seen.entry(name.to_string()).or_insert(0) += 1; + ".[].sha", + ])?; + let mut decided: BTreeMap = BTreeMap::new(); // name -> failing + for sha in recent.lines() { + let runs = gh(&[ + "api", + &format!("repos/{repo}/commits/{sha}/check-runs?per_page=100"), + "--jq", + r#".check_runs[]|select(.status=="completed")|[.name,.conclusion]|@tsv"#, + ]) + .unwrap_or_default(); + for line in runs.lines() { + let mut it = line.splitn(2, '\t'); + let (Some(name), Some(conc)) = (it.next(), it.next()) else { + continue; + }; + decided + .entry(name.to_string()) + .or_insert(conc == "failure" || conc == "timed_out"); + } + } + for (name, failing) in &decided { + if *failing { + *seen.entry(name.clone()).or_insert(0) += 1; + } } let merged = gh(&[ "api", @@ -236,9 +469,11 @@ fn ready(n: u64, repo: Option<&str>, baseline: usize, wait: bool, poll: u64, mer let mut new_here = Vec::new(); for name in &mine { match seen.get(name) { - Some(k) => println!(" {name}\n also failing in {k} other place(s) — pre-existing"), + Some(k) => { + println!(" {name}\n also failing in {k} other place(s) — pre-existing") + } None => { - println!(" {name}\n NOT failing on {branch} or in the last {baseline} merged PRs"); + println!(" {name}\n NOT failing on recent {branch} commits or in the last {baseline} merged PRs"); new_here.push(name.clone()); } } @@ -254,14 +489,35 @@ fn ready(n: u64, repo: Option<&str>, baseline: usize, wait: bool, poll: u64, mer if merge { println!(); let out = Command::new("gh") - .args(["pr", "merge", &n.to_string(), "--repo", &repo, - "--squash", "--delete-branch"]) + .args([ + "pr", + "merge", + &n.to_string(), + "--repo", + &repo, + "--squash", + "--delete-branch", + ]) .output() .context("failed to run gh pr merge")?; if out.status.success() { - println!("Merged."); + // `gh pr merge` exiting zero is not the same as the content + // being on the branch: it also succeeds when it merely + // enables auto-merge, and a squash-merged stack orphans + // whatever sat on top of it. Ask the API instead of the + // exit code, and name what was verified. + match confirm_merged(&repo, n) { + Ok(sha) => println!("Merged — {sha} is on the default branch."), + Err(e) => { + println!("Merge command succeeded but the branch does not show it: {e}"); + println!("Do NOT report this as merged. Check the pull request."); + } + } } else { - println!("Merge refused: {}", String::from_utf8_lossy(&out.stderr).trim()); + println!( + "Merge refused: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); } } } else { @@ -318,6 +574,9 @@ mod tests { } else { "DO NOT MERGE" }; - assert_eq!(verdict, "WAIT", "pending must outrank an empty failure list"); + assert_eq!( + verdict, "WAIT", + "pending must outrank an empty failure list" + ); } } diff --git a/docs/NOW.md b/docs/NOW.md index 863939e1e3..37c4b64da3 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,71 @@ +# NOW -- the NOW gate matched a path nobody edits (2026-08-20) + +Last updated: 2026-08-20 + +## the NOW gate matched a path nobody edits (Closes #2249) + +- pre-commit gated on ^NOW.md$ while every pull request updates docs/NOW.md, so the not-staged warning fired on correct commits; the gate now accepts either path +- Root NOW.md turns out to be a regular 390-line file last touched 2026-08-09, not the symlink .gitattributes describes -- two NOW files have been diverging silently (issue filed, the resolution is the owner's call) + +# NOW -- the gate confirms a merge against the branch, not the exit code (2026-08-20) + +Last updated: 2026-08-20 + +## the gate confirms a merge against the branch, not the exit code (Closes #2249) + +- pr ready --merge no longer trusts gh pr merge's exit status: it re-asks the API for merged=true, takes the merge commit sha, and compares it against the default branch -- printing 'Merged — is on the default branch' +- gh pr merge also exits zero when it merely enables auto-merge, and a squash-merged stack orphans whatever sat on top of it; both read as success from the exit code alone +- When the command succeeds but the branch does not show it, the gate says so and tells the caller not to report it as merged + +# NOW -- tri pr landed: status is not content (2026-08-20) + +Last updated: 2026-08-20 + +## tri pr landed: status is not content (Closes #2249) + +- tri pr landed --probe : fetches the default branch's copy of every file the PR touched and reports PRESENT/ABSENT per probe -- because 'merged' and 'closed' both read as success in a list, and a squash-merged stack orphans whatever sat on top of it +- Validated on the real case that prompted it: the closed stack-mate reports ABSENT, the merged rescue reports PRESENT on all three probes +- Probes compare with whitespace flattened (prose gets re-wrapped) but case-sensitively (case is text); a probe the repository could satisfy without the change proves nothing + +# NOW -- the gate says which pull request it is watching (2026-08-20) + +Last updated: 2026-08-20 + +## the gate says which pull request it is watching (Closes #2249) + +- pr ready prints repo#number before the wait loop and on every waiting line: two gates logging to one file produced a transcript where one PR's 'Merged.' read as the other's verdict +- Diagnosing through a channel shared by two sources is the broken-ruler error this project's own doctrine is named after -- the tool now makes the misread impossible + +# NOW -- asof reads the claims from a declaration in the repository (2026-08-20) + +Last updated: 2026-08-20 + +## asof reads the claims from a declaration in the repository (Closes #2249) + +- tri fleet asof --from .tri/environments.json: each capability claim names the environments it rests on, and the check answers per claim -- 'is the project fine' has no answer, claims fail independently +- Live on this repository's own declaration: 3 of 5 claims SAYABLE (t27.ai, the hiking site, GitHub), 2 STALE with the reason printed (bus empty), exit 1 +- The declaration lives next to the code because remembering which URL backs which claim is exactly the step that gets skipped + +# NOW -- tri fleet asof generalises the bus check to every environment (2026-08-20) + +Last updated: 2026-08-20 + +## tri fleet asof generalises the bus check to every environment (Closes #2249) + +- tri fleet asof --url ... [--needs-hardware]: HEADs each URL a claim depends on and optionally the bus, then says whether the claim may be repeated in the present tense; non-zero exit when any environment is unreachable +- A timeout is reported as 'cannot verify', not as 'the site is down' -- only one of those is the owner's problem +- Verified live: t27.ai and floripahikegpro.vercel.app reachable, the bus empty, exit 1 under --needs-hardware + +# NOW -- tri fleet answers whether the hardware is there (2026-08-20) + +Last updated: 2026-08-20 + +## tri fleet answers whether the hardware is there (Closes #2249) + +- tri fleet scan: reads the USB tree and serial nodes, reports bridges by serial, and on an empty bus prints the sentence to send the owner ('this needs hands, not code') instead of a problem to code around +- --expect N exits non-zero when fewer boards are present than the plan assumes, so a script cannot proceed on a fleet that is not there +- tri now itself is fixed here: the bullet above starts with a flag name and clap rejected it -- writing this entry is what found the bug + # NOW -- formal now tests the RTL it just built (2026-08-20) Last updated: 2026-08-20 @@ -145,6 +213,14 @@ Last updated: 2026-08-19 fifo.v (#2240), absorbed by the old warning-gate 40 minutes after the 32/32 claim. A refusal on the record beats a vacuous green +# NOW -- tri pr ready walks the branch; tri now writes this file (2026-08-19) + +Last updated: 2026-08-19 + +## tri pr ready walks the branch; tri now writes this file (Closes #2235) + +- pr ready: a check absent from the default branch HEAD is no longer 'green on the branch' -- each check is scored by the most recent of the last 15 default-branch commits it actually ran on; the illusion that hid the broken master build (#2227) cannot recur +- tri now: prepends this exact entry frame (title, date, Closes ref, bullets) -- this entry was written by the command it documents # NOW -- the tri CLI wave lands: mutate, pr ready, synth/sweep area (2026-08-20)