From a91675c099fcb9f3c4600db0b545c51f70072db3 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Wed, 19 Aug 2026 21:47:59 +0700 Subject: [PATCH 01/13] feat(tri): pr-ready per-check commit-walk baseline; tri now writes the NOW frame The default branch's HEAD is not the default branch: a check that did not run on HEAD shows neither green nor red there, and reading HEAD alone made a broken master build look green (#2227). pr ready now scores each check by the most recent of the last 15 default-branch commits it actually ran on. tri now prepends the rigid docs/NOW.md entry frame; its own NOW entry was written by the command. Smoke: live verdict against a merged PR is sane. Closes #2235 Co-Authored-By: Claude Fable 5 --- cli/tri/src/main.rs | 11 ++++- cli/tri/src/nownote.rs | 81 ++++++++++++++++++++++++++++++++ cli/tri/src/prcheck.rs | 103 ++++++++++++++++++++++++++++++++--------- docs/NOW.md | 8 ++++ 4 files changed, 178 insertions(+), 25 deletions(-) create mode 100644 cli/tri/src/nownote.rs diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index 72f11fc5a3..a600a284c3 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -12,11 +12,12 @@ 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 +71,11 @@ 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 this pull request actually safe to merge? Pr { #[command(subcommand)] @@ -699,6 +705,7 @@ 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::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..289d6f93f6 --- /dev/null +++ b/cli/tri/src/nownote.rs @@ -0,0 +1,81 @@ +//! `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. + #[arg(long = "bullet", required = 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..67aaed0be7 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -108,11 +108,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,10 +137,24 @@ 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", + ])?, }; // Anything still running makes the answer provisional, so say so rather @@ -195,22 +217,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 +278,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 +298,24 @@ 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."); } else { - println!("Merge refused: {}", String::from_utf8_lossy(&out.stderr).trim()); + println!( + "Merge refused: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); } } } else { @@ -318,6 +372,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..7962aaa2d4 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -145,6 +145,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) From cdc67e43f369e39fea6305736756adc7c44b351c Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 02:29:35 +0700 Subject: [PATCH 02/13] feat(tri): tri fleet -- is the hardware this plan assumes actually attached? Three notes in this project claim hardware capability in the present tense while no 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. tri fleet scan reads the USB tree (ioreg) and the serial device nodes, excludes Bluetooth and the debug console, and reports what is actually present. On an empty bus it refuses to be useful in the wrong direction: it prints the sentence to send the owner instead of a problem to code around. --expect N exits non-zero when the fleet is short. On a non-macOS host it says it cannot tell, because 'no boards' and 'cannot tell' are different answers and only one is safe to act on. Smoke-tested against the real bus: 0 bridges, 0 serial nodes, non-zero exit under --expect 3. Closes #2249 Co-Authored-By: Claude Fable 5 --- cli/tri/src/fleet.rs | 151 +++++++++++++++++++++++++++++++++++++++++++ cli/tri/src/main.rs | 7 ++ 2 files changed, 158 insertions(+) create mode 100644 cli/tri/src/fleet.rs diff --git a/cli/tri/src/fleet.rs b/cli/tri/src/fleet.rs new file mode 100644 index 0000000000..c75233c034 --- /dev/null +++ b/cli/tri/src/fleet.rs @@ -0,0 +1,151 @@ +//! `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::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, + }, +} + +pub fn run(cmd: &FleetCmd) -> Result<()> { + match cmd { + FleetCmd::Scan { expect } => scan(*expect), + } +} + +/// 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 a600a284c3..559835084a 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; mod depin; +mod fleet; mod fpga; mod gates; mod hooks; @@ -76,6 +77,11 @@ enum Commands { #[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)] @@ -706,6 +712,7 @@ fn main() -> Result<()> { 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)?, From 0777f03e0ec2654d91bd8c533db59d6b773643cf Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 02:30:12 +0700 Subject: [PATCH 03/13] fix(tri): a NOW bullet may start with a flag name (Closes #2249) Writing this wave's own NOW entry failed: the bullet describing --expect was read by clap as an argument. allow_hyphen_values, and the entry that found it is the entry this commit adds. --- cli/tri/src/nownote.rs | 6 +++++- docs/NOW.md | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/tri/src/nownote.rs b/cli/tri/src/nownote.rs index 289d6f93f6..af3f3a84f4 100644 --- a/cli/tri/src/nownote.rs +++ b/cli/tri/src/nownote.rs @@ -19,7 +19,11 @@ pub enum NowCmd { title: String, /// Bullet lines, repeatable. At least one is required — an entry /// with no content is exactly the vacuous touch the gate invites. - #[arg(long = "bullet", required = true)] + /// + /// `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)] diff --git a/docs/NOW.md b/docs/NOW.md index 7962aaa2d4..600dc776e0 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,13 @@ +# 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 From e53e714a4676d305d45e3a39047dc5aa964574de Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 02:40:01 +0700 Subject: [PATCH 04/13] feat(tri): tri fleet asof -- check the environments a claim depends on The bus was one environment; a deployed site is another, and both go stale the same way: the note says 'works' because it worked, and nothing announces the regression. asof HEADs each URL the claim depends on, adds the bus under --needs-hardware, and reports whether the capability may be stated in the present tense today -- non-zero exit when it may not. A curl timeout is reported as 'cannot verify', distinct from a site that answers with an error: only one of those is the owner's problem. Verified live: t27.ai and the hiking site reachable, bus empty, exit 1 under --needs-hardware. --- cli/tri/src/fleet.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++ docs/NOW.md | 10 +++++ 2 files changed, 101 insertions(+) diff --git a/cli/tri/src/fleet.rs b/cli/tri/src/fleet.rs index c75233c034..a148bd01eb 100644 --- a/cli/tri/src/fleet.rs +++ b/cli/tri/src/fleet.rs @@ -26,12 +26,103 @@ pub enum FleetCmd { #[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, + }, } pub fn run(cmd: &FleetCmd) -> Result<()> { match cmd { FleetCmd::Scan { expect } => scan(*expect), + FleetCmd::Asof { + urls, + needs_hardware, + } => asof(urls, *needs_hardware), + } +} + +/// 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. diff --git a/docs/NOW.md b/docs/NOW.md index 600dc776e0..e076590bc4 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,13 @@ +# 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 From a958f4e67cbb898334e68ed354beaaf0017aeaff Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 03:09:20 +0700 Subject: [PATCH 05/13] feat(tri): asof --from reads the claims from a declaration in the repository Remembering which environments back which claim is the step that gets skipped, so the claims live in .tri/environments.json next to the code they describe. The check answers per claim, not per project: 'is the project fine' has no answer, because claims rest on different environments and those fail independently. Live against this repository's own declaration: three claims SAYABLE (t27.ai, the hiking site, GitHub), two STALE with the reason printed next to each (bus empty), exit 1. Co-Authored-By: Claude Fable 5 --- .tri/environments.json | 25 +++++++++++++ cli/tri/src/fleet.rs | 81 +++++++++++++++++++++++++++++++++++++++++- docs/NOW.md | 10 ++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 .tri/environments.json 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 index a148bd01eb..dbf7b46933 100644 --- a/cli/tri/src/fleet.rs +++ b/cli/tri/src/fleet.rs @@ -16,6 +16,7 @@ use anyhow::{Context, Result}; use clap::Subcommand; +use std::path::PathBuf; use std::process::Command; #[derive(Subcommand)] @@ -39,19 +40,97 @@ pub enum FleetCmd { /// 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, - } => 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. diff --git a/docs/NOW.md b/docs/NOW.md index e076590bc4..a8125a0e82 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,13 @@ +# 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 From 029d8ca2437d80ce5e58b7154df23ab7c93356cb Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 03:25:46 +0700 Subject: [PATCH 06/13] fix(tri): the gate says which pull request it is watching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 'waiting: N of M' line carried no identity, so two gates logging to one file produced a transcript where one PR's 'Merged.' read as the other's verdict — I misread exactly that today. Diagnosing through a channel shared by two sources is the broken-ruler error this project's doctrine is named after; the identity now prints before the wait loop and on every line, which makes the misread impossible rather than unlikely. Closes #2249 --- cli/tri/src/prcheck.rs | 9 ++++++++- docs/NOW.md | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cli/tri/src/prcheck.rs b/cli/tri/src/prcheck.rs index 67aaed0be7..8f6abdc19d 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -157,6 +157,13 @@ fn ready( ])?, }; + // 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; @@ -188,7 +195,7 @@ fn ready( }; 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. diff --git a/docs/NOW.md b/docs/NOW.md index a8125a0e82..447c222c5b 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,12 @@ +# 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 From 0217789559f1c76aa8a2d622ea3b80e92cf09ffe Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 03:58:04 +0700 Subject: [PATCH 07/13] feat(tri): tri pr landed -- status is not content '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. landed fetches the default branch's copy of every file the pull request touched and reports each probe PRESENT or ABSENT. Whitespace is flattened on both sides because prose gets re-wrapped; case stays significant because case is text. Validated against the real case: the orphaned stack-mate reports ABSENT, the merged rescue reports PRESENT. Closes #2249 --- cli/tri/src/prcheck.rs | 144 +++++++++++++++++++++++++++++++++++++++++ docs/NOW.md | 10 +++ 2 files changed, 154 insertions(+) diff --git a/cli/tri/src/prcheck.rs b/cli/tri/src/prcheck.rs index 8f6abdc19d..0d0f1cc043 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -22,6 +22,33 @@ 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. + #[arg(long = "probe", required = true)] + probes: Vec, + }, Ready { /// Pull request number. number: u64, @@ -59,7 +86,124 @@ 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()) +} + +/// 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 { diff --git a/docs/NOW.md b/docs/NOW.md index 447c222c5b..a1e2d663b2 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,13 @@ +# 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 From aec1c37eaf017d6687c9659e38f9e0d551816b58 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 04:10:59 +0700 Subject: [PATCH 08/13] docs(tri): a fourth way a landing probe lies -- later rewording Sweeping eight merged pull requests with landed found one ABSENT that was the probe's fault again: the string came from a pull request still open, which had rewritten that sentence. All eight had in fact landed. Probe with the wording the pull request introduced, not with today's text. --- cli/tri/src/prcheck.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/tri/src/prcheck.rs b/cli/tri/src/prcheck.rs index 0d0f1cc043..ea95dda627 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -45,7 +45,10 @@ pub enum PrCmd { /// 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. + /// 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, }, From 96ce128610078b8ff1020f8c9d2f2c8df60f0f77 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 04:23:20 +0700 Subject: [PATCH 09/13] chore(git): docs/NOW.md merges by union (Closes #2249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pull request must touch docs/NOW.md and it is prepend-only, so two branches conflict there by construction — seven times in this campaign, each resolved identically by hand: keep both entries. The union driver makes that resolution the rule instead of a habit somebody will skip. From 1531713487b5f36eb43df16eb8c3706637ab58d3 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 04:24:21 +0700 Subject: [PATCH 10/13] chore(git): the NOW.md merge rule names the symlink, not the file that conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes declares `NOW.md merge=theirs`, but the file every branch touches is docs/NOW.md, and `theirs` is not a built-in driver — it needs a per-clone .git/config entry a fresh checkout does not have. Both halves failed silently: docs/NOW.md conflicted on seven branches in this campaign and was resolved identically by hand each time, keeping both entries. `docs/NOW.md merge=union` is built in, needs no setup, and is that resolution. Closes #2249 --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitattributes b/.gitattributes index 842c2776d7..5f78eded7c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,11 @@ # 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 + +# 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 From 49023845bbeb60ff96ef8a7a8042c7efda2d9d7c Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 04:39:53 +0700 Subject: [PATCH 11/13] fix(tri): confirm a merge against the branch, not against an exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. The gate now re-asks the API for merged=true, takes the merge commit sha, and confirms that commit is contained in the default branch before printing anything. The message names what was verified ('Merged — is on the default branch'), and on a mismatch it says plainly not to report the pull request as merged. This is the same defect the campaign hit twice: a verdict that reads as success while the work reached nothing. Closes #2249 --- cli/tri/src/prcheck.rs | 50 +++++++++++++++++++++++++++++++++++++++++- docs/NOW.md | 10 +++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/cli/tri/src/prcheck.rs b/cli/tri/src/prcheck.rs index ea95dda627..58d669871d 100644 --- a/cli/tri/src/prcheck.rs +++ b/cli/tri/src/prcheck.rs @@ -178,6 +178,43 @@ fn landed(n: u64, repo: Option<&str>, probes: &[String]) -> Result<()> { 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 { @@ -464,7 +501,18 @@ fn ready( .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: {}", diff --git a/docs/NOW.md b/docs/NOW.md index a1e2d663b2..999d1c68cb 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,13 @@ +# 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 From 05470ea6220737aa60203a8ee2317d902dff8090 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 05:08:05 +0700 Subject: [PATCH 12/13] chore(git): the other two merge drivers were dead in every fresh clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitattributes declared merge=append-log for the experience JSONL and merge=theirs for the NOW.md symlink. Neither is a built-in driver: both need a merge..driver entry in .git/config, and nothing in this checkout installs one — so in any clone but the author's, both lines did nothing and the files conflicted normally. Union is built in and is exactly the intended behaviour for append-only files. Same defect as the docs/NOW.md rule fixed alongside it: a rule that needs undocumented local setup works only on the machine that wrote it. --- .gitattributes | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitattributes b/.gitattributes index 5f78eded7c..9137892c8b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,11 +6,18 @@ # ═════════════════════════════════════════════════════════════════════════════════════════ # 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 From 693564bc270055efcc6757993c1720bbe0bb5ed3 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 20 Aug 2026 05:10:29 +0700 Subject: [PATCH 13/13] fix(hooks): the NOW gate matched a path nobody edits The pre-commit gate tested '^NOW.md$' while every pull request updates docs/NOW.md, so the not-staged warning fired on commits that were doing exactly the right thing. It now accepts either path. Root NOW.md is a regular 390-line file last changed 2026-08-09 -- not the symlink .gitattributes describes -- so the two have been diverging in silence. Whether the root copy becomes a symlink, is deleted, or keeps its own content is the owner's call; filed rather than decided here. --- .githooks/pre-commit | 13 +++++++++---- docs/NOW.md | 9 +++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) 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/docs/NOW.md b/docs/NOW.md index 999d1c68cb..37c4b64da3 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,12 @@ +# 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