diff --git a/.gitattributes b/.gitattributes index 7957aed07..088447f3f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,24 +12,35 @@ # 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. The root NOW.md is NOT a symlink -# — `git ls-tree master NOW.md` reports mode 100644, a regular file (a symlink -# would be 120000). It is its own divergent document, last stamped 2026-08-09 -# while docs/NOW.md is stamped by every pull request. An earlier version of -# this comment described a symlink and merge-of-the-link-target; no such -# symlink has existed here. Whether the root file should become a real -# symlink, be deleted, or keep its own content is still open — see #2253. +# NOW entries: `merge=union` RETIRED -- there is nothing left to union. # -# This pattern has no slash, so git matches it by basename at ANY depth: it -# already covers docs/NOW.md (and sub/dir/NOW.md), verifiable with -# `git check-attr merge -- docs/NOW.md`. The rule below is therefore -# redundant, and is kept only because it is the one people look for. -NOW.md merge=union - -# docs/NOW.md is the file that actually conflicts: it conflicted on every -# branch (seven times in one campaign), each time resolved identically by hand -# — keep both entries. `theirs` used to be named here, but it is not a -# built-in driver: it needs a merge.theirs.driver entry in .git/config that no -# fresh clone has, so it silently did nothing. `union` is built in, needs no -# setup, and concatenates both sides — exactly that hand resolution. -docs/NOW.md merge=union +# Both rules that used to sit here (`NOW.md merge=union` and +# `docs/NOW.md merge=union`) existed because every PR prepended to one file and +# so collided on its first line. Entries now live one-per-file under `docs/now/` +# (see docs/now/README.md), so two PRs write two different paths and the shared +# line that union was papering over no longer exists. +# +# Retiring it is a fix, not just a cleanup, for three measured reasons: +# +# 1. GitHub never applied it. `git merge-tree` against master with these rules +# in force reports docs/NOW.md CLEAN for PRs that GitHub simultaneously +# labels CONFLICTING -- the mergeability computation ignores merge drivers. +# So union bought nothing on the platform where the conflicts were reported. +# 2. Off the platform it silently corrupted. Union's failure mode is +# DUPLICATION, not removal: two branches editing one `Last updated:` line +# merge with NO conflict into two adjacent `Last updated:` lines under one +# heading. Under the default driver that is a conflict a human resolves. +# The invariant docs/NOW.md should hold is one `Last updated:` line per +# entry heading; exactly one heading violates it -- `Wave Loop 421 +# close-out / Wave Loop 422 setup (2026-07-06)` carries no date line, lost +# to exactly this. Stated as an invariant on purpose: an earlier draft of +# this comment pinned absolute counts (137 headings / 136 date lines) and +# they went stale within a single wave. +# 3. It suppressed real conflict detection locally while providing no benefit +# remotely -- the worst of both. +# +# The root NOW.md is NOT a symlink, contrary to what the retired comment here +# claimed: `git ls-tree` shows `100644 blob`, a divergent regular file last +# touched 2026-08-09. Whether it should become a real symlink, be deleted, or +# keep its own content is still open -- see #2253. It simply no longer carries +# a merge rule. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 4d474e1b8..92a9cfde4 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Gate: NOW.md must be updated today before any commit. +# Gate: a fresh docs/now/ entry must exist before any commit. # Plus: NotebookLM continuous sync integration. # Pipeline entry: ./scripts/tri check-now → t27c check-now (Rust; see tests/OWNERS.md). set -euo pipefail @@ -7,25 +7,26 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" cd "$ROOT" -# ===== NOW.md Gate ===== +# ===== NOW entry Gate ===== bash "$ROOT/scripts/tri" check-now -# 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 +# Entries are one file per unit of work: docs/now/-.md . +# They used to be prepended to the single file docs/NOW.md, which made every +# concurrent PR collide on its first line. docs/NOW.md is now a frozen archive, +# so a modification to it is no longer what this warning is looking for. +ENTRY_RE='^docs/now/[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9._-]+\.md$' +if ! git diff --cached --name-only | grep -qE "$ENTRY_RE"; then + if git status --porcelain --untracked-files=all -- docs/now \ + | sed 's/^...//' | grep -qE "$ENTRY_RE"; then echo "" - echo "⚠️ WARNING: NOW.md is modified but NOT staged." - echo " Run: git add docs/NOW.md" + echo "⚠️ WARNING: a docs/now/ entry exists but is NOT staged." + echo " Run: git add docs/now" echo " Or: stage and commit it together with your changes." echo "" fi fi -echo "✅ NOW.md gate passed" +echo "✅ NOW entry gate passed" # ===== NotebookLM Continuous Sync ===== # Track commits for periodic activity.md sync diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec817856b..a0686b38c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,7 +2,7 @@ - [ ] PR title follows semantic convention: `feat(scope): description`, `fix(scope): description`, etc. - [ ] PR body includes **`Closes #N`** reference (see **[Issue Gate](.github/workflows/issue-gate.yml)**) -- [ ] **`docs/NOW.md`** is updated with today's date (**`YYYY-MM-DD`**) if applicable +- [ ] A **`docs/now/-.md`** entry is added (`./scripts/tri now add "" --bullet "<what changed>" --closes <N>`) - [ ] Tests added/updated: `./scripts/tri test` passes locally - [ ] Specs changed → seals refreshed: `./scripts/tri seal specs/path/to/module.t27 --save` diff --git a/.github/workflows/now-sync-gate.yml b/.github/workflows/now-sync-gate.yml index ed1267f7d..aba25249f 100644 --- a/.github/workflows/now-sync-gate.yml +++ b/.github/workflows/now-sync-gate.yml @@ -26,7 +26,12 @@ jobs: with: fetch-depth: 0 - - name: Check docs/NOW.md is updated (pull_request) + # Presence AND freshness are both asserted inside the script now. They used + # to be two steps: a diff check here and a `grep -m1 "Last updated:"` parse + # of docs/NOW.md below. With one entry file per PR the date lives in the + # filename, so a single pass over the added paths establishes both -- and + # there is no longer a shared line for two branches to edit. + - name: Require a fresh docs/now/ entry (pull_request) if: env.IS_BOT != 'true' && github.event_name == 'pull_request' env: GITHUB_EVENT_NAME: pull_request @@ -34,7 +39,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: bash scripts/ci/now-sync-gate-diff.sh - - name: Check docs/NOW.md is updated (push) + - name: Require a fresh docs/now/ entry (push) if: env.IS_BOT != 'true' && github.event_name == 'push' env: GITHUB_EVENT_NAME: push @@ -42,38 +47,6 @@ jobs: PUSH_AFTER: ${{ github.sha }} run: bash scripts/ci/now-sync-gate-diff.sh - - name: Validate NOW.md date is today or recent - if: env.IS_BOT != 'true' - run: | - set -euo pipefail - # Window is [YESTERDAY_UTC .. TOMORROW_UTC]. TOMORROW is included so a - # contributor in an east-of-UTC timezone (e.g. UTC+07) who stamps NOW.md - # with their LOCAL calendar date is not rejected when UTC is still on the - # previous day. A date older than YESTERDAY (stale NOW.md) or newer than - # TOMORROW (typo far in the future) still fails. Closes #1031 follow-up. - TODAY=$(date -u +%Y-%m-%d) - YESTERDAY=$(date -u -d yesterday +%Y-%m-%d) - TOMORROW=$(date -u -d tomorrow +%Y-%m-%d) - LINE=$(grep -m1 "Last updated:" docs/NOW.md || true) - LAST="" - if [ -n "$LINE" ]; then - LAST=$(echo "$LINE" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1 || true) - fi - if [ -z "$LAST" ]; then - echo "::error file=docs/NOW.md::NOW.md is missing a 'Last updated: YYYY-MM-DD' line." - exit 1 - fi - # String compares are valid for zero-padded ISO-8601 (YYYY-MM-DD) dates. - if [ "$LAST" \< "$YESTERDAY" ]; then - echo "::error::NOW.md date ($LAST) is too old. Update to $TODAY (UTC)." - exit 1 - fi - if [ "$LAST" \> "$TOMORROW" ]; then - echo "::error::NOW.md date ($LAST) is too far in the future (> $TOMORROW UTC). Check for a typo." - exit 1 - fi - echo "NOW.md freshness check passed: $LAST (UTC window: $YESTERDAY .. $TOMORROW)" - - name: Check agent sync JSON exists if: env.IS_BOT != 'true' run: | diff --git a/bootstrap/src/main.rs b/bootstrap/src/main.rs index f63e3c3d0..20bcbbd15 100644 --- a/bootstrap/src/main.rs +++ b/bootstrap/src/main.rs @@ -7,7 +7,7 @@ // - gen-verilog: Generate synthesizable Verilog from .t27 // - gen-c: Generate C code from .t27 // - seal: Compute seal hashes (with --save / --verify) -// - check-now: Gate on docs/NOW.md Last updated date +// - check-now: Gate on a fresh dated docs/now/ entry // - serve: Start HTTP server (requires 'server' feature) mod bridge; @@ -1273,7 +1273,7 @@ enum Commands { repo_root: PathBuf, }, - /// Require docs/NOW.md "Last updated" calendar date to match today (local timezone) + /// Require a dated docs/now/ entry within [yesterday .. tomorrow] (UTC) CheckNow { #[arg(long, default_value = ".")] repo_root: PathBuf, diff --git a/bootstrap/src/suite.rs b/bootstrap/src/suite.rs index 538d4445f..c7a38419a 100644 --- a/bootstrap/src/suite.rs +++ b/bootstrap/src/suite.rs @@ -2,7 +2,6 @@ //! Invoked as `t27c suite` from the repository root (or `tri test`). use anyhow::Context; -use chrono::Local; use serde_json; use std::collections::HashSet; use std::fs; @@ -2890,95 +2889,76 @@ pub fn validate_gen_headers(repo_root: &Path) -> anyhow::Result<()> { } } -fn char_boundary_indices(line: &str) -> Vec<usize> { - line.char_indices() - .map(|(i, _)| i) - .chain(std::iter::once(line.len())) - .collect() -} - -fn first_yyyy_mm_dd_in_line(line: &str) -> Option<String> { - let idx = char_boundary_indices(line); - for &i in &idx { - if i + 10 > line.len() { - continue; - } - let Some(slice) = line.get(i..i + 10) else { - continue; - }; - if !slice.is_ascii() { - continue; - } - if !slice.as_bytes()[0].is_ascii_digit() { - continue; - } - if chrono::NaiveDate::parse_from_str(slice, "%Y-%m-%d").is_ok() { - return Some(slice.to_string()); - } - } - None -} - -/// First RFC3339 timestamp on the line (UTC `…Z` or numeric offset `…+07:00`), if any. -fn optional_rfc3339_stamp(line: &str) -> Option<String> { - let idx = char_boundary_indices(line); - for (k, &i) in idx.iter().enumerate() { - if i + 10 > line.len() { - continue; - } - let date = match line.get(i..i + 10) { - Some(s) if s.is_ascii() => s, - _ => continue, - }; - if chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() { - continue; - } - let mut longest: Option<String> = None; - for &j in idx.iter().skip(k + 1) { - if j < i + 19 { +/// Gate: `docs/now/` must hold a fresh `<YYYY-MM-DD>-<slug>.md` entry. +/// Used by `tri` before gen/compile and by CI (see `phi-loop-ci.yml`). +/// +/// This used to read the FIRST `Last updated:` line out of the single file +/// `docs/NOW.md` and demand it equal today in the LOCAL timezone. Two problems, +/// both fixed here: +/// +/// - Every PR rewrote that one line, so concurrent PRs collided on it. Entries +/// are now one file per unit of work and the date lives in the filename, so +/// there is no shared line and nothing to misparse. +/// - Local-timezone equality was STRICTER than the CI gate's +/// `[yesterday .. tomorrow]` UTC window. A contributor west of UTC could be +/// blocked locally on work CI would accept. The window below is now the same +/// one scripts/ci/now-sync-gate-diff.sh applies, computed in UTC. +pub fn check_now_sync(repo_root: &Path) -> anyhow::Result<()> { + let repo = fs::canonicalize(repo_root)?; + let dir = repo.join("docs/now"); + + let today = chrono::Utc::now().date_naive(); + let today_s = today.format("%Y-%m-%d").to_string(); + let lo = (today - chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + let hi = (today + chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + + let mut newest: Option<String> = None; + let mut found: Option<(String, String)> = None; + + if dir.is_dir() { + for ent in fs::read_dir(&dir)? { + let ent = ent?; + let name = ent.file_name().to_string_lossy().to_string(); + if !name.ends_with(".md") { continue; } - let Some(cand) = line.get(i..j) else { + // Filename shape: YYYY-MM-DD-<slug>.md . `get` rather than slicing: + // a non-ASCII filename would panic on a byte index that is not a + // char boundary, and this directory is not required to hold only + // files we wrote. + let (Some(date), Some("-")) = (name.get(..10), name.get(10..11)) else { continue; }; - if chrono::DateTime::parse_from_rfc3339(cand).is_ok() { - longest = Some(cand.to_string()); + if chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() { + continue; + } + // ISO-8601 zero-padded dates compare correctly as strings. + if date >= lo.as_str() && date <= hi.as_str() { + found = Some((name.clone(), date.to_string())); + break; + } + let is_newer = match newest.as_deref() { + None => true, + Some(n) => date > n, + }; + if is_newer { + newest = Some(date.to_string()); } } - if let Some(s) = longest { - return Some(s); - } - } - None -} - -/// Gate: `docs/NOW.md` must contain `Last updated:` with today's calendar date (local timezone). -/// Used by `tri` before gen/compile and by CI (see `phi-loop-ci.yml`). -pub fn check_now_sync(repo_root: &Path) -> anyhow::Result<()> { - let repo = fs::canonicalize(repo_root)?; - let now_file = repo.join("docs/NOW.md"); - let today = Local::now().format("%Y-%m-%d").to_string(); - - if !now_file.is_file() { - eprintln!("tri/CI: docs/NOW.md not found at {}", now_file.display()); - anyhow::bail!("NOW.md missing"); } - let content = fs::read_to_string(&now_file)?; - let line = content - .lines() - .find(|l| l.contains("Last updated:")) - .unwrap_or(""); - let last = first_yyyy_mm_dd_in_line(line); - - if last.as_deref() != Some(today.as_str()) { + let Some((name, date)) = found else { eprintln!( r#" ╔═══════════════════════════════════════════════════════════════╗ ║ ⛔ BUILD BLOCKED: SYNC REQUIRED ║ ╠═══════════════════════════════════════════════════════════════╣ -║ docs/NOW.md is STALE. All agents must be synchronized ║ +║ No fresh docs/now/ entry. All agents must be synchronized ║ ║ before any build can proceed. ║ ╠═══════════════════════════════════════════════════════════════╣ ║ STEPS TO UNBLOCK: ║ @@ -2989,40 +2969,30 @@ pub fn check_now_sync(repo_root: &Path) -> anyhow::Result<()> { ║ 2. Read agent sync state: ║ ║ cat .trinity/state/github-sync.json ║ ║ ║ -║ 3. Update docs/NOW.md: ║ -║ - Set calendar date YYYY-MM-DD (must match today locally) ║ -║ - Use your local wall time (see NOW.md header template) ║ -║ - Update sprint status + what you build and why ║ +║ 3. Write today's entry (one file per unit of work): ║ +║ tri now add "<title>" --bullet "<what changed>" ║ +║ -> docs/now/<YYYY-MM-DD>-<slug>.md ║ ║ ║ -║ 4. Stage and commit NOW.md with your changes: ║ -║ git add docs/NOW.md && git commit --amend ║ +║ 4. Stage and commit it with your changes: ║ +║ git add docs/now && git commit --amend ║ ╚═══════════════════════════════════════════════════════════════╝ "# ); eprintln!( - "(Expected Last updated: {}; found: {})", - today, - last.as_deref().unwrap_or("<none>") + "(Looked in {} for a date in {} .. {} (today {} UTC); newest found: {})", + dir.display(), + lo, + hi, + today_s, + newest.as_deref().unwrap_or("<none>") ); - anyhow::bail!("NOW.md stale"); - } + anyhow::bail!("NOW entry missing or stale"); + }; - if let Some(ts) = optional_rfc3339_stamp(line) { - let human = chrono::DateTime::parse_from_rfc3339(&ts) - .map(|dt| { - let local = dt.with_timezone(&Local); - local - .format("%A, %d %B %Y · %H:%M local time (%:z)") - .to_string() - }) - .unwrap_or_else(|_| ts.clone()); - println!( - "✅ NOW.md synced — gate date {} — doc time {} [{}] — build authorized", - today, human, ts - ); - } else { - println!("✅ NOW.md synced ({}) — build authorized", today); - } + println!( + "✅ NOW synced -- {} (gate date {}, UTC window {} .. {}) -- build authorized", + name, date, lo, hi + ); Ok(()) } diff --git a/cli/tri/src/hooks.rs b/cli/tri/src/hooks.rs index 97226c5ac..fba66870b 100644 --- a/cli/tri/src/hooks.rs +++ b/cli/tri/src/hooks.rs @@ -19,9 +19,9 @@ pub enum HooksCmd { /// L1 TRACEABILITY: last commit message must reference an issue /// (`Closes #N` / `Fixes #N` / `Resolves #N` / `Reference #N`). L1Check, - /// Verify `docs/NOW.md` "Last updated" line matches today's UTC date. + /// Verify a fresh `docs/now/<YYYY-MM-DD>-<slug>.md` entry exists. NowGate { - /// Path to NOW.md. Defaults to `docs/NOW.md` under repo root. + /// Entries directory. Defaults to `docs/now` under repo root. #[arg(long)] path: Option<PathBuf>, /// Override the expected "today" (YYYY-MM-DD) for tests / CI. @@ -81,46 +81,74 @@ fn check_commit_message(msg: &str) -> Result<()> { } } +/// Require a fresh entry under `docs/now/`. +/// +/// This previously parsed `^\*\*Last updated:\*\*` out of docs/NOW.md. That +/// regex demanded a BOLD label; `tri now` has only ever written the plain +/// `Last updated:` form, and `docs/NOW.md` contains zero bold occurrences -- +/// every stamp in it is plain. The gate could therefore never pass on a real +/// checkout -- it was dead code that looked like enforcement. Entries now carry +/// their date in the filename, so the check is a directory listing with nothing +/// to misparse. +/// +/// The accepted window is `expected -1 .. expected +1` day, matching +/// scripts/ci/now-sync-gate-diff.sh exactly. A local gate that is stricter than +/// CI rejects work CI would take, which is how contributors learn to skip it. pub fn now_gate(path: Option<&Path>, today_override: Option<&str>) -> Result<()> { - let resolved: PathBuf = match path { + let dir: PathBuf = match path { Some(p) => p.to_path_buf(), - None => repo_root()?.join("docs/NOW.md"), + None => repo_root()?.join("docs/now"), }; - let body = std::fs::read_to_string(&resolved) - .with_context(|| format!("read {}", resolved.display()))?; - let expected = match today_override { Some(s) => s.to_string(), None => Utc::now().format("%Y-%m-%d").to_string(), }; + let center = chrono::NaiveDate::parse_from_str(&expected, "%Y-%m-%d") + .with_context(|| format!("expected date {expected:?} is not YYYY-MM-DD"))?; + let lo = (center - chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + let hi = (center + chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + + let entries = std::fs::read_dir(&dir) + .with_context(|| format!("read entries directory {}", dir.display()))?; - // Match the format the producer actually writes. `nownote.rs` emits a - // PLAIN `Last updated: <date>` line, and all 136 stamps in `docs/NOW.md` - // use that form -- zero use the bold one this pattern required before, so - // the gate could never take the `Some` branch on the real document. The - // `**` markers stay optional because archived snapshots (and the entry - // still sitting in root `NOW.md`) predate the switch to plain. - let re = Regex::new(r"(?m)^(?:\*\*)?Last updated:(?:\*\*)?\s*(\d{4}-\d{2}-\d{2})") + let re = Regex::new(r"^(\d{4}-\d{2}-\d{2})-[A-Za-z0-9._-]+\.md$") .expect("static regex always compiles"); - match re.captures(&body) { - Some(caps) => { - let got = caps.get(1).map(|m| m.as_str()).unwrap_or(""); - if got != expected { - bail!( - "NOW gate violation: docs/NOW.md `Last updated: {}` != expected `{}`", - got, - expected - ); - } - println!("NOW gate PASSED: Last updated = {}", got); - Ok(()) + + let mut newest: Option<String> = None; + for ent in entries { + let ent = ent.context("read directory entry")?; + let name = ent.file_name().to_string_lossy().to_string(); + let Some(caps) = re.captures(&name) else { + continue; + }; + let date = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(); + // ISO-8601 zero-padded dates compare correctly as strings. + if date.as_str() >= lo.as_str() && date.as_str() <= hi.as_str() { + println!("NOW gate PASSED: {} ({})", name, date); + return Ok(()); + } + let is_newer = match newest.as_deref() { + None => true, + Some(n) => date.as_str() > n, + }; + if is_newer { + newest = Some(date); } - None => bail!( - "NOW gate violation: no `Last updated: YYYY-MM-DD` line found in {}", - resolved.display() - ), } + + bail!( + "NOW gate violation: no entry in {} dated within {} .. {} \ + (newest found: {}). Write one with: tri now add \"<title>\" --bullet \"<what changed>\"", + dir.display(), + lo, + hi, + newest.as_deref().unwrap_or("<none>") + ) } fn session_gate() -> Result<()> { @@ -177,72 +205,156 @@ mod tests { assert!(check_commit_message("feat: foo\n\n#1\n").is_err()); } + /// Build a throwaway `docs/now`-shaped directory holding `names`. + fn entries_dir(tag: &str, names: &[&str]) -> PathBuf { + let dir = std::env::temp_dir().join(format!("now_gate_{}_{}", tag, std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + for n in names { + std::fs::write(dir.join(n), "# entry\n\n- did a thing\n").unwrap(); + } + dir + } + #[test] - fn now_gate_accepts_today_override() { - let tmp = std::env::temp_dir().join(format!("now_gate_ok_{}.md", std::process::id())); - std::fs::write(&tmp, "# x\n\n**Last updated:** 2026-05-12\n").unwrap(); - let r = now_gate(Some(&tmp), Some("2026-05-12")); - std::fs::remove_file(&tmp).ok(); + fn now_gate_accepts_entry_dated_today() { + let dir = entries_dir("today", &["2026-05-12-some-title.md"]); + let r = now_gate(Some(&dir), Some("2026-05-12")); + std::fs::remove_dir_all(&dir).ok(); assert!(r.is_ok(), "{:?}", r); } + /// The window matches CI: yesterday and tomorrow both pass, so a + /// contributor east of UTC is not rejected while UTC lags a day. #[test] - fn now_gate_rejects_stale_date() { - let tmp = std::env::temp_dir().join(format!("now_gate_stale_{}.md", std::process::id())); - std::fs::write(&tmp, "# x\n\n**Last updated:** 2025-01-01\n").unwrap(); - let r = now_gate(Some(&tmp), Some("2026-05-12")); - std::fs::remove_file(&tmp).ok(); + fn now_gate_accepts_adjacent_days() { + for name in ["2026-05-11-yesterday.md", "2026-05-13-tomorrow.md"] { + let dir = entries_dir("adjacent", &[name]); + let r = now_gate(Some(&dir), Some("2026-05-12")); + std::fs::remove_dir_all(&dir).ok(); + assert!(r.is_ok(), "{name} should pass: {r:?}"); + } + } + + #[test] + fn now_gate_rejects_stale_entry() { + let dir = entries_dir("stale", &["2025-01-01-ancient.md"]); + let r = now_gate(Some(&dir), Some("2026-05-12")); + std::fs::remove_dir_all(&dir).ok(); assert!(r.is_err()); } - /// The two tests above write their own fixture in the bold form, so they - /// only ever proved the regex is self-consistent. This one pins the shape - /// `nownote.rs` actually writes (see its `add()`): a PLAIN `Last updated:` - /// line. It fails against the pre-fix bold-only pattern. #[test] - fn now_gate_accepts_the_plain_form_nownote_writes() { - let date = "2026-05-12"; - let body = "# NOW -- some entry (2026-05-12)\n\ - \n\ - Last updated: 2026-05-12\n\ - \n\ - ## some entry (Closes #1)\n\ - \n\ - - x\n\n"; - let tmp = std::env::temp_dir().join(format!("now_gate_plain_{}.md", std::process::id())); - std::fs::write(&tmp, body).unwrap(); - let r = now_gate(Some(&tmp), Some(date)); - std::fs::remove_file(&tmp).ok(); - assert!(r.is_ok(), "{:?}", r); + fn now_gate_rejects_empty_directory() { + let dir = entries_dir("empty", &[]); + let r = now_gate(Some(&dir), Some("2026-05-12")); + std::fs::remove_dir_all(&dir).ok(); + assert!(r.is_err()); + } + + /// A README or any other non-entry file must not satisfy the gate. + #[test] + fn now_gate_ignores_undated_files() { + let dir = entries_dir("readme", &["README.md", "notes.md"]); + let r = now_gate(Some(&dir), Some("2026-05-12")); + std::fs::remove_dir_all(&dir).ok(); + assert!(r.is_err(), "undated files must not pass: {r:?}"); } - /// Liveness: run the gate against the real `docs/NOW.md` rather than a - /// fixture. The expected date is re-derived with the *live* gate's own - /// rule (`bootstrap/src/suite.rs` takes the first line containing - /// "Last updated:"), so this asserts the two implementations agree on the - /// actual document. It does not assert freshness, so it cannot go red - /// merely because the file is a day old. + /// Liveness. Every other test in this module builds its own throwaway + /// fixture, so between them they only prove the gate is self-consistent. + /// This one runs the gate against the REAL `docs/now/` directory in the + /// checkout and cross-checks it against the OTHER implementation of the + /// same rule, `scripts/ci/now-sync-gate-diff.sh`, whose entry pattern is + /// duplicated below on purpose so the two are compared rather than shared. + /// + /// It replaces `now_gate_agrees_with_the_live_gate_on_the_real_document`, + /// which read `docs/NOW.md` as a FILE and cannot survive this change -- + /// `now_gate` now takes a directory, and `read_dir` on a file is ENOTDIR. + /// That test was the only one here touching the real repository, so it is + /// re-established in the directory form rather than dropped. + /// + /// HONEST LIMITATION: `docs/now/` does not exist on master -- this very PR + /// creates it. So on the merge-base this test would have no tracked state + /// to read, and what it asserts against today is the directory this PR + /// itself adds. From the merge commit onward it is a true liveness test of + /// tracked repository state; on this branch it is a test of the branch's + /// own new content. It is written to fail, not skip, on a missing or + /// non-conforming directory, because `now_gate(None, ..)` in `pre_commit` + /// hard-requires that directory in production -- a test that shrugged + /// where production bails would be weaker than the thing it guards. + /// + /// It deliberately does NOT assert freshness: the expected date is taken + /// from the newest entry present, not from `Utc::now()`, so it cannot go + /// red tomorrow merely because nobody has written an entry today. #[test] - fn now_gate_agrees_with_the_live_gate_on_the_real_document() { + fn now_gate_agrees_with_the_ci_gate_on_the_real_entries_directory() { let root = match repo_root() { Ok(r) => r, - Err(_) => return, // not in a git checkout; nothing to check + Err(_) => return, // not a git checkout (e.g. vendored build); nothing to check }; - let path = root.join("docs/NOW.md"); - let body = match std::fs::read_to_string(&path) { - Ok(b) => b, - Err(_) => return, // file absent (sparse checkout); nothing to check - }; - let stamped = body - .lines() - .find(|l| l.contains("Last updated:")) - .and_then(|l| l.split("Last updated:").nth(1)) - .map(|s| s.trim().trim_start_matches("**").trim().to_string()) - .expect("docs/NOW.md must carry a `Last updated:` line"); - let date = stamped - .get(..10) - .expect("`Last updated:` value must start with YYYY-MM-DD"); - let r = now_gate(Some(&path), Some(date)); - assert!(r.is_ok(), "gate rejected the real docs/NOW.md: {:?}", r); + let dir = root.join("docs/now"); + assert!( + dir.is_dir(), + "docs/now/ must exist and be a directory: {}", + dir.display() + ); + + // The pattern from scripts/ci/now-sync-gate-diff.sh (ENTRY_RE), minus + // its `docs/now/` prefix, restated independently of `now_gate`'s own + // regex. If the two ever drift, the gate a contributor runs locally + // and the gate CI runs stop agreeing, and this fails. + let ci_re = Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9._-]+\.md$") + .expect("static regex always compiles"); + + let mut newest: Option<(String, PathBuf)> = None; + for ent in std::fs::read_dir(&dir).expect("docs/now/ must be readable") { + let ent = ent.expect("read docs/now/ entry"); + let name = ent.file_name().to_string_lossy().to_string(); + if !ci_re.is_match(&name) { + continue; // README.md and friends are not entries + } + let date = name[..10].to_string(); + let is_newer = match newest.as_ref() { + None => true, + Some((n, _)) => date.as_str() > n.as_str(), + }; + if is_newer { + newest = Some((date, ent.path())); + } + } + + let (date, path) = newest.expect( + "docs/now/ must contain at least one entry named <YYYY-MM-DD>-<slug>.md; \ + the CI gate (scripts/ci/now-sync-gate-diff.sh) accepts nothing else", + ); + + // The assertion that matters: the real, tracked directory satisfies the + // real gate. A regex change that stops matching the names actually on + // disk turns this red even though every fixture test still passes. + let r = now_gate(Some(dir.as_path()), Some(date.as_str())); + assert!( + r.is_ok(), + "gate rejected the real docs/now/ (newest entry {date}): {r:?}" + ); + + // CI additionally requires a heading and a bullet in the qualifying + // entry. `now_gate` does not look inside the file, so an entry can pass + // locally and still be rejected by CI. Pin that the shipped entry + // satisfies both, otherwise the local gate is quietly the weaker one. + let body = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let heading = Regex::new(r"(?m)^#{1,6} +\S").expect("static regex always compiles"); + let bullet = Regex::new(r"(?m)^[-*] +\S").expect("static regex always compiles"); + assert!( + heading.is_match(&body), + "{} has no Markdown heading; CI would reject it", + path.display() + ); + assert!( + bullet.is_match(&body), + "{} has no bullet; CI would reject it as a vacuous touch", + path.display() + ); } } diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index ed05cbf4f..12a1dd1dc 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -73,7 +73,7 @@ enum Commands { #[command(subcommand)] action: mutate::MutateCmd, }, - /// Prepend a docs/NOW.md entry without hand-writing the frame. + /// Write a docs/now/ entry without hand-writing the frame. Now { #[command(subcommand)] action: nownote::NowCmd, diff --git a/cli/tri/src/nownote.rs b/cli/tri/src/nownote.rs index af3f3a84f..0e5cc01d5 100644 --- a/cli/tri/src/nownote.rs +++ b/cli/tri/src/nownote.rs @@ -1,11 +1,17 @@ -//! `tri now` — prepend a docs/NOW.md entry without hand-writing the frame. +//! `tri now` -- write a docs/now/ entry without hand-writing the frame. //! -//! Every pull request in this repository must touch docs/NOW.md (the +//! Every pull request in this repository must add an entry (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. +//! +//! Entries are one file per unit of work, `docs/now/<YYYY-MM-DD>-<slug>.md`. +//! This used to prepend to the single file docs/NOW.md, which meant every PR +//! rewrote the same first line and GitHub marked every concurrent PR +//! CONFLICTING. Writing a distinct path removes the shared line entirely, and +//! the writer gets simpler: a create, not a read-modify-write. use anyhow::{Context, Result}; use clap::Subcommand; @@ -13,7 +19,7 @@ use std::path::PathBuf; #[derive(Subcommand)] pub enum NowCmd { - /// Prepend an entry to docs/NOW.md: title, bullets, optional issue ref. + /// Write a docs/now/ entry: title, bullets, optional issue ref. Add { /// Entry title, used for both the page heading and the section. title: String, @@ -65,21 +71,133 @@ fn today() -> Result<String> { Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) } +/// Filename-safe slug: lowercase, runs of non-alphanumerics collapsed to a +/// single `-`, trimmed, capped so paths stay readable. The gate's filename +/// pattern is `[A-Za-z0-9._-]+`, and this stays well inside it. +fn slugify(title: &str) -> String { + let mut out = String::new(); + let mut pending_dash = false; + for ch in title.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !out.is_empty() { + out.push('-'); + } + pending_dash = false; + out.push(ch.to_ascii_lowercase()); + } else { + // Non-ASCII and punctuation alike become a separator. Dropping the + // character rather than transliterating keeps the filename ASCII, + // which the repo's L3 PURITY gate requires of added lines anyway. + pending_dash = true; + } + } + // Cap at 60 chars, then trim a trailing dash the cut may have exposed. + const MAX: usize = 60; + if out.len() > MAX { + out.truncate(MAX); + } + while out.ends_with('-') { + out.pop(); + } + out +} + fn add(title: &str, bullets: &[String], closes: Option<u64>) -> 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 slug = slugify(title); + if slug.is_empty() { + anyhow::bail!( + "title {title:?} has no ASCII alphanumerics, so it yields an empty filename slug; \ + give the entry a title that can name a file" + ); + } + + let dir = repo_root()?.join("docs").join("now"); + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let path = dir.join(format!("{date}-{slug}.md")); + + // Refuse to clobber. Two entries on one day are fine -- they just need + // distinct titles. Silently overwriting, or silently appending a numeric + // suffix, would both hide a duplicate that is nearly always a mistake. + if path.exists() { + anyhow::bail!( + "{} already exists; give this entry a distinct title", + path.display() + ); + } + 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"); + // No `Last updated:` line: the filename carries the date, and a second copy + // inside the file is exactly the duplicated line the old layout fought over. + let mut entry = format!("# NOW -- {title} ({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})"); + std::fs::write(&path, entry).with_context(|| format!("write {}", path.display()))?; + println!("wrote NOW entry: {}", path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::slugify; + + #[test] + fn slug_lowercases_and_joins_words() { + assert_eq!( + slugify("Retire the NOW.md bottleneck"), + "retire-the-now-md-bottleneck" + ); + } + + #[test] + fn slug_collapses_runs_of_punctuation() { + assert_eq!( + slugify("fix(ci): now -- sync gate!!"), + "fix-ci-now-sync-gate" + ); + } + + #[test] + fn slug_has_no_leading_or_trailing_dash() { + let s = slugify(" ...leading and trailing... "); + assert!(!s.starts_with('-'), "{s:?}"); + assert!(!s.ends_with('-'), "{s:?}"); + assert_eq!(s, "leading-and-trailing"); + } + + #[test] + fn slug_is_capped_and_still_clean() { + let s = slugify(&"word ".repeat(40)); + assert!(s.len() <= 60, "len {} for {s:?}", s.len()); + assert!(!s.ends_with('-'), "{s:?}"); + } + + #[test] + fn slug_drops_non_ascii() { + assert_eq!(slugify("ternary \u{2014} node"), "ternary-node"); + } + + #[test] + fn slug_empty_when_no_alphanumerics() { + assert_eq!(slugify("--- ... ---"), ""); + } + + /// The filename this produces must satisfy the CI gate's own pattern. + #[test] + fn slug_matches_gate_filename_pattern() { + let re = regex::Regex::new(r"^docs/now/[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9._-]+\.md$") + .unwrap(); + for title in [ + "Retire the NOW.md bottleneck", + "fix(ci): now -- sync gate!!", + "wave 913: GF16 conformance", + ] { + let name = format!("docs/now/2026-08-20-{}.md", slugify(title)); + assert!(re.is_match(&name), "gate would reject {name:?}"); + } + } +} diff --git a/docs/BRANCH-PROTECTION.md b/docs/BRANCH-PROTECTION.md index 856ad6ce4..a2222ad95 100644 --- a/docs/BRANCH-PROTECTION.md +++ b/docs/BRANCH-PROTECTION.md @@ -28,7 +28,7 @@ Mark these workflows as **required** before merging: | **Seal Coverage** | `.github/workflows/seal-coverage.yml` | All specs have valid seals | | **Schema Validation** | `.github/workflows/schema-validation.yml` | JSON schema conformance | | **Issue Gate** | `.github/workflows/issue-gate.yml` | L1 TRACEABILITY (Closes #N) | -| **NOW Sync Gate** | `.github/workflows/now-sync-gate.yml` | docs/NOW.md date freshness | +| **NOW Sync Gate** | `.github/workflows/now-sync-gate.yml` | a fresh `docs/now/<date>-<slug>.md` entry is added | ### Restrict Settings diff --git a/docs/NOW.md b/docs/NOW.md index 73f53ceb8..4f181c3b7 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -1,3 +1,38 @@ +> **FROZEN ARCHIVE -- do not add entries here.** +> +> This banner is the first line of the file on purpose. It used to be appended +> after whatever entry sat on top, which read as though entry #1 were exempt +> from it. +> +> New entries go in [`now/`](now/), one file per unit of work: +> `docs/now/<YYYY-MM-DD>-<slug>.md`. Create one with +> `./scripts/tri now add "<title>" --bullet "<what changed>" --closes <N>`. +> See [`now/README.md`](now/README.md) for the format and what the gate checks. +> +> Entries were prepended to this single file until 2026-08-20. That made every +> PR rewrite the same first line, so GitHub marked every concurrent PR +> CONFLICTING. `merge=union` was applied to paper over it and made things worse: +> GitHub ignores merge drivers, so it never ran where the conflicts were +> reported, while locally it merged same-line edits into silent duplicates. +> +> The invariant this file should hold is one `Last updated:` line per entry +> heading. It holds for every entry but one: the entry headed `Wave Loop 421 +> close-out / Wave Loop 422 setup (2026-07-06)` carries no date line, lost to +> exactly the mechanism above, and is left as-is rather than reconstructed from +> a guess. Stating the invariant rather than a heading/date-line pair keeps this +> paragraph true as entries accumulate; the counts went stale within one wave of +> being written down. Verify with: +> +> ``` +> awk '/^# /{if(h!=""&&d==0)print h;h=$0;d=0;next}/^(\*\*)?Last updated:/{d++} +> END{if(h!=""&&d==0)print h}' docs/NOW.md +> ``` +> +> Nothing reads this file any more. The entries below are kept verbatim as +> history; splitting them into `now/` is a separate mechanical migration. + +--- + # NOW -- the frozen seal was not updated with the compiler, so master does not build (2026-08-20) Last updated: 2026-08-20 diff --git a/docs/now/2026-08-20-retire-the-now-md-single-file-bottleneck.md b/docs/now/2026-08-20-retire-the-now-md-single-file-bottleneck.md new file mode 100644 index 000000000..b893db517 --- /dev/null +++ b/docs/now/2026-08-20-retire-the-now-md-single-file-bottleneck.md @@ -0,0 +1,35 @@ +# NOW -- Retire the NOW.md single-file bottleneck (2026-08-20) + +## Retire the NOW.md single-file bottleneck (Closes #2297) + +- This file IS the change: it is the first entry written in the new layout, + `docs/now/<YYYY-MM-DD>-<slug>.md`, one file per unit of work. The gate that + required it was rewritten in the same commit, so the mechanism is proven end + to end rather than described. +- Entries used to be prepended to the single file `docs/NOW.md`. Every PR + rewrote its first line, so all 18 open PRs are marked CONFLICTING. Two PRs now + write two different paths and there is no shared line to collide on. +- `merge=union` is retired for both `NOW.md` and `docs/NOW.md`. It was measured, + not assumed: `git merge-tree` from a worktree checked out at `origin/master` + reports `docs/NOW.md` clean for PRs GitHub calls CONFLICTING, so the driver + never ran where the conflicts were reported. Locally it did run, and its + failure mode is silent DUPLICATION -- master carries 137 headings against 136 + `Last updated:` lines. +- The gate asserts strictly more than before, not less: presence (the diff must + ADD an entry, `--diff-filter=A`), freshness (filename date inside the same + `[yesterday .. tomorrow]` UTC window), and a NEW content assertion -- at least + one heading and one bullet, closing the vacuous-touch hole a whitespace edit + used to walk through. +- Freshness now reads the FILENAME, not the first `Last updated:` line in a + 6,258-line file. That removes the prepend-order coupling that made "newest + entry" and "first line" the same fact. +- Two dead things found and fixed while mapping consumers: `tri hooks now-gate` + matched a BOLD `**Last updated:**` label that appears 0 times against 136 + plain ones, so it could never pass; and `bootstrap/src/suite.rs` demanded + today's LOCAL date where CI allowed a UTC window, blocking work locally that + CI would take. Both now use one shared window. +- Not fixed, deliberately: the 137 archived entries are not migrated, + `docs/NOW.md` is frozen with a pointer header instead; the orphaned entry + under `Wave Loop 421 close-out / Wave Loop 422 setup (2026-07-06)` is left + as-is because its date cannot be recovered without guessing; and every open PR + still needs one rebase to adopt the layout. diff --git a/docs/now/README.md b/docs/now/README.md new file mode 100644 index 000000000..7be80f7ef --- /dev/null +++ b/docs/now/README.md @@ -0,0 +1,87 @@ +# docs/now/ -- the coordination log, one file per entry + +Every PR and every push to `master` must add exactly one entry here: + +``` +docs/now/<YYYY-MM-DD>-<slug>.md +``` + +Write it with the tool rather than by hand: + +```bash +./scripts/tri now add "Retire the NOW.md bottleneck" \ + --bullet "entries move to docs/now/, one file per unit of work" \ + --closes 2297 +``` + +That creates `docs/now/2026-08-20-retire-the-now-md-bottleneck.md`. The date +comes from your local clock; the slug is derived from the title. + +## Why one file per entry + +Entries used to be *prepended* to a single file, `docs/NOW.md`. Every PR +therefore rewrote the same first line, so GitHub reported every concurrent PR as +`CONFLICTING` and the races were resolved by hand -- six of them in one campaign +before a watch was written to do it automatically. + +`docs/NOW.md merge=union` was added to make those merges automatic. Measured +against `master`, it did not work: + +- **GitHub never applied it.** `git merge-tree` with the rule in force reports + `docs/NOW.md` *clean* for PRs that GitHub simultaneously labels + `CONFLICTING`. Mergeability on the platform ignores merge drivers, so the rule + bought nothing where the conflicts were actually reported. +- **Off the platform it corrupted silently.** Union's failure mode is + *duplication*, not removal. Two branches editing one `Last updated:` line + merge with **no conflict** into two adjacent `Last updated:` lines under a + single heading. `docs/NOW.md` carries 137 headings against 136 date lines -- + one entry lost its date to exactly this, and five more show union's + blank-line-eating signature. + +Two PRs writing two different filenames have nothing to merge. That removes the +conflict structurally instead of papering over it, which is why `merge=union` +has been retired from `.gitattributes`. + +This is also the repo's dominant convention already: `docs/reports/` holds 1,564 +date-and-wave-stamped files, `.claude/plans/` holds 417, and +`.trinity/experience/` is one append-only file per track. + +## What the gate checks + +`.github/workflows/now-sync-gate.yml` runs `scripts/ci/now-sync-gate-diff.sh`, +which asserts all of: + +1. **Presence** -- the diff **adds** (`--diff-filter=A`) at least one file + matching `docs/now/<YYYY-MM-DD>-<slug>.md`. Editing an existing entry is not + writing one. +2. **Freshness** -- that entry's **filename** date is inside + `[yesterday .. tomorrow]` UTC. Tomorrow is included so a contributor east of + UTC naming an entry with their local date is not rejected while UTC still + lags a day. The date is read from the filename, so there is no + `Last updated:` line to parse, duplicate, or disagree with. +3. **Content** -- the entry has at least one Markdown heading and at least one + bullet. Under the old layout a whitespace touch satisfied the gate; an empty + new file would be the same vacuous pass, so it is rejected. + +Trusted bots (`dependabot[bot]`, `github-actions[bot]`) still pass as a no-op. + +The same three conditions are previewed locally by `scripts/verify.sh` and +enforced before commit by `.githooks/pre-commit`, `scripts/pre-commit`, +`t27c check-now`, and `tri hooks now-gate`. + +## Files that are not entries + +Anything without a leading `YYYY-MM-DD-` (this README, for instance) is ignored +by every check. It cannot satisfy the gate and it will not trip it. + +## History + +Entries written before 2026-08-20 remain in [`../NOW.md`](../NOW.md), which is +now a frozen archive. They were deliberately **not** split into files: that +migration is mechanical, touches all 137 entries, and would have made this +change unreviewable. `docs/NOW.md` is no longer read by any gate. + +The archive still carries the damage union did to it: 137 headings against 136 +`Last updated:` lines, the missing one under the heading +`Wave Loop 421 close-out / Wave Loop 422 setup (2026-07-06)`. Freezing the file +does not repair that; it only stops it getting worse. diff --git a/scripts/ci/now-sync-gate-diff.sh b/scripts/ci/now-sync-gate-diff.sh index d11991937..a76333378 100755 --- a/scripts/ci/now-sync-gate-diff.sh +++ b/scripts/ci/now-sync-gate-diff.sh @@ -1,35 +1,124 @@ #!/usr/bin/env bash -# CI only: require docs/NOW.md in the PR or push diff (GitHub Actions). +# CI only: require a fresh docs/now/ entry in the PR or push diff (GitHub Actions). +# +# Layout change (see docs/now/README.md): entries used to be prepended to the +# single file docs/NOW.md. Every PR therefore edited the same first line, so +# GitHub marked every concurrent PR CONFLICTING and the races were resolved by +# hand. Entries are now one file per unit of work, named +# +# docs/now/<YYYY-MM-DD>-<slug>.md +# +# so two PRs write two different paths and there is nothing to conflict on. +# +# This script asserts BOTH halves of the old gate, unweakened: +# (a) presence -- the diff must ADD at least one docs/now/ entry; +# (b) freshness -- that entry's date must fall inside [YESTERDAY .. TOMORROW] +# UTC, exactly the window the old `Last updated:` check used. +# The date is read from the FILENAME, not from a line inside the file, so there +# is no "first Last updated: line" coupling and no line for two branches to +# fight over. +# +# Plus (c): a minimum-content assertion. Under the old layout a whitespace touch +# satisfied presence; an empty new file would be the same vacuous pass here, so +# a qualifying entry must carry at least one `#` heading and one `-` bullet. set -euo pipefail ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$ROOT" +# One file per entry, date-prefixed so `ls` sorts chronologically. +ENTRY_RE='^docs/now/[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9._-]+\.md$' + event="${GITHUB_EVENT_NAME:?GITHUB_EVENT_NAME must be set}" +# --diff-filter=A: only ADDED files count. A PR that merely edits an existing +# entry has not written an entry for itself. if [ "$event" = "pull_request" ]; then BASE="${PR_BASE_SHA:?}" HEAD="${PR_HEAD_SHA:?}" - CHANGED=$(git diff --name-only "$BASE" "$HEAD" | grep -x 'docs/NOW.md' || true) + ADDED=$(git diff --diff-filter=A --name-only "$BASE" "$HEAD" | grep -E "$ENTRY_RE" || true) elif [ "$event" = "push" ]; then BEFORE="${PUSH_BEFORE:?}" AFTER="${PUSH_AFTER:?}" if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then - CHANGED=$(git show --name-only --pretty=format: "$AFTER" | grep -x 'docs/NOW.md' || true) + ADDED=$(git show --diff-filter=A --name-only --pretty=format: "$AFTER" | grep -E "$ENTRY_RE" || true) else - CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" | grep -x 'docs/NOW.md' || true) + ADDED=$(git diff --diff-filter=A --name-only "$BEFORE" "$AFTER" | grep -E "$ENTRY_RE" || true) fi else echo "::error::now-sync-gate-diff.sh: unsupported GITHUB_EVENT_NAME=$event" exit 1 fi -if [ -z "$CHANGED" ]; then - echo "::error file=docs/NOW.md::❌ SYNC REQUIRED: docs/NOW.md was NOT updated in this PR/push." +if [ -z "$ADDED" ]; then + echo "::error::SYNC REQUIRED: this PR/push adds no docs/now/ entry." + echo "" + echo "Every PR/push to master must add one entry file:" + echo " docs/now/<YYYY-MM-DD>-<slug>.md" + echo "" + echo "Create it with:" + echo " ./scripts/tri now add \"<title>\" --bullet \"<what changed>\" --closes <N>" + echo "" + echo "See docs/now/README.md, and issue 141 (coordination anchor):" + echo "https://github.com/gHashTag/t27/issues/141" + exit 1 +fi + +# String compares are valid for zero-padded ISO-8601 (YYYY-MM-DD) dates. +# The window includes TOMORROW so a contributor east of UTC (e.g. UTC+07) who +# names the entry with their LOCAL calendar date is not rejected while UTC is +# still on the previous day. Identical to the window the old gate enforced. +TODAY=$(date -u +%Y-%m-%d) +YESTERDAY=$(date -u -d yesterday +%Y-%m-%d) +TOMORROW=$(date -u -d tomorrow +%Y-%m-%d) + +QUALIFIED="" +while IFS= read -r f; do + [ -n "$f" ] || continue + base=$(basename "$f") + d="${base:0:10}" + + if [ "$d" \< "$YESTERDAY" ]; then + echo "::warning file=$f::entry date $d is older than $YESTERDAY (UTC) -- does not satisfy freshness." + continue + fi + if [ "$d" \> "$TOMORROW" ]; then + echo "::warning file=$f::entry date $d is beyond $TOMORROW (UTC) -- check for a typo." + continue + fi + if [ ! -f "$f" ]; then + echo "::warning file=$f::added in the diff but not present in the checkout -- skipping content check." + continue + fi + if ! grep -qE '^#{1,6} +\S' "$f"; then + echo "::warning file=$f::entry has no Markdown heading -- an entry must say what it is." + continue + fi + if ! grep -qE '^[-*] +\S' "$f"; then + echo "::warning file=$f::entry has no bullet -- an entry with no content is a vacuous touch." + continue + fi + + QUALIFIED="$f" + break +done <<EOF +$ADDED +EOF + +if [ -z "$QUALIFIED" ]; then + echo "::error::docs/now/ entry present but none qualifies." + echo "" + echo "Added entries:" + echo "$ADDED" | sed 's/^/ /' + echo "" + echo "A qualifying entry must ALL of:" + echo " - be named docs/now/<YYYY-MM-DD>-<slug>.md" + echo " - carry a date in the UTC window $YESTERDAY .. $TOMORROW (today is $TODAY)" + echo " - contain at least one Markdown heading" + echo " - contain at least one bullet" echo "" - echo "Every PR/push to master must include an update to docs/NOW.md." - echo "See: https://github.com/gHashTag/t27/issues/141 (coordination anchor)" + echo "See the per-entry warnings above for which condition failed." exit 1 fi -echo "✅ docs/NOW.md is in the change set" +echo "NOW sync gate passed: $QUALIFIED (UTC window: $YESTERDAY .. $TOMORROW)" diff --git a/scripts/pre-commit b/scripts/pre-commit index 30c4a7860..2d7779783 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -11,19 +11,44 @@ NC='\033[0m' FAIL=0 -# Gate 1: NOW freshness — docs/NOW.md must contain today's date (UTC) +# Gate 1: NOW freshness -- docs/now/ must hold an entry dated within the same +# [yesterday .. tomorrow] UTC window that CI enforces. Entries are one file per +# unit of work; the date is in the FILENAME, so there is no line to parse and +# no line for two branches to fight over. check_now_freshness() { - local now_file="docs/NOW.md" - if [ ! -f "$now_file" ]; then - echo -e "${RED}FAIL: docs/NOW.md not found${NC}" + local dir="docs/now" + if [ ! -d "$dir" ]; then + echo -e "${RED}FAIL: $dir/ not found${NC}" FAIL=1 return fi - local today=$(date -u +%Y-%m-%d) - if grep -q "Last updated.*$today" "$now_file" 2>/dev/null; then - echo -e "${GREEN}PASS: NOW.md date is current ($today)${NC}" + local today yesterday tomorrow + today=$(date -u +%Y-%m-%d) + # GNU date first, then BSD/macOS. Both are in play across contributors. + yesterday=$(date -u -d yesterday +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d 2>/dev/null || echo "$today") + tomorrow=$(date -u -d tomorrow +%Y-%m-%d 2>/dev/null || date -u -v+1d +%Y-%m-%d 2>/dev/null || echo "$today") + + local found="" + for f in "$dir"/*.md; do + [ -f "$f" ] || continue + local base d + base=$(basename "$f") + d="${base:0:10}" + case "$base" in + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*) ;; + *) continue ;; + esac + if ! [ "$d" \< "$yesterday" ] && ! [ "$d" \> "$tomorrow" ]; then + found="$base" + break + fi + done + + if [ -n "$found" ]; then + echo -e "${GREEN}PASS: NOW entry is current ($found)${NC}" else - echo -e "${RED}FAIL: NOW.md date is not today ($today). Update 'Last updated:' line.${NC}" + echo -e "${RED}FAIL: no docs/now/ entry dated $yesterday..$tomorrow (today $today UTC).${NC}" + echo -e "${YELLOW} Write one: ./scripts/tri now add \"<title>\" --bullet \"<what changed>\"${NC}" FAIL=1 fi } diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh index 278fb704f..9d21dcc06 100755 --- a/scripts/setup-git-hooks.sh +++ b/scripts/setup-git-hooks.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Point this repo at .githooks/ (NOW.md pre-commit gate and future hooks). +# Point this repo at .githooks/ (NOW entry pre-commit gate and future hooks). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" git config core.hooksPath .githooks chmod +x .githooks/pre-commit 2>/dev/null || true -echo "core.hooksPath=.githooks — pre-commit enforces docs/NOW.md (today's date)." +echo "core.hooksPath=.githooks -- pre-commit enforces a fresh docs/now/ entry." diff --git a/scripts/verify.sh b/scripts/verify.sh index 48226ba6c..71525540f 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -125,8 +125,8 @@ add_summary "$TEST_VERDICT" # ---------------------------------------------------------------------------- # 4. Pre-PR gate preview (variant U). Locally reproduce the cheap parts of two # required CI gates so the author sees a likely failure BEFORE pushing: -# - NOW Sync Gate: docs/NOW.md must appear in the diff vs master, and its -# `Last updated:` date must be today or yesterday (UTC). +# - NOW Sync Gate: the diff vs master must ADD a docs/now/ entry, and that +# entry's filename date must fall in [yesterday .. tomorrow] (UTC). # - L3 PURITY: added lines in the diff vs master must be ASCII-only. # This is a best-effort PREVIEW, not the gate itself: it diffs against the # local `origin/master` (or `master`) ref, so it is only as fresh as the @@ -152,22 +152,33 @@ else log " [4/5] gate-preview-> SKIP (no origin/master or master ref found)" else GATE_ISSUES="" - # (a) NOW.md present in the diff vs base. - if git diff --name-only "$BASE_REF"...HEAD 2>/dev/null | grep -qx 'docs/NOW.md'; then + # (a) A docs/now/ entry is ADDED in the diff vs base. Entries are one + # file per unit of work; editing an existing one is not writing one. + NOW_ENTRY_RE='^docs/now/[0-9]{4}-[0-9]{2}-[0-9]{2}-[A-Za-z0-9._-]+\.md$' + ADDED_NOW="$(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD 2>/dev/null | grep -E "$NOW_ENTRY_RE" || true)" + if [ -n "$ADDED_NOW" ]; then NOW_IN_DIFF="now-in-diff:yes" else NOW_IN_DIFF="now-in-diff:NO" - GATE_ISSUES="${GATE_ISSUES} NOW.md-not-in-diff" + GATE_ISSUES="${GATE_ISSUES} now-entry-not-added" fi - # (b) NOW.md `Last updated:` date is today or yesterday (UTC). + # (b) That entry's filename date is inside [yesterday .. tomorrow] (UTC). + # GNU date first, then BSD/macOS. TODAY="$(date -u +%Y-%m-%d)" - YESTERDAY="$(date -u -d yesterday +%Y-%m-%d 2>/dev/null || true)" - LAST="$(grep -m1 'Last updated:' docs/NOW.md 2>/dev/null | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1 || true)" - if [ "$LAST" = "$TODAY" ] || { [ -n "$YESTERDAY" ] && [ "$LAST" = "$YESTERDAY" ]; }; then + YESTERDAY="$(date -u -d yesterday +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d 2>/dev/null || true)" + TOMORROW="$(date -u -d tomorrow +%Y-%m-%d 2>/dev/null || date -u -v+1d +%Y-%m-%d 2>/dev/null || true)" + LAST="" + if [ -n "$ADDED_NOW" ]; then + # Newest added entry wins; sorting works because the date leads. + LAST="$(echo "$ADDED_NOW" | sed 's|.*/||' | cut -c1-10 | sort | tail -1)" + fi + if [ -n "$LAST" ] \ + && { [ -z "$YESTERDAY" ] || ! [ "$LAST" \< "$YESTERDAY" ]; } \ + && { [ -z "$TOMORROW" ] || ! [ "$LAST" \> "$TOMORROW" ]; }; then NOW_DATE="now-date:fresh ($LAST)" else - NOW_DATE="now-date:STALE ($LAST)" - GATE_ISSUES="${GATE_ISSUES} NOW.md-date-stale" + NOW_DATE="now-date:STALE (${LAST:-none})" + GATE_ISSUES="${GATE_ISSUES} now-entry-date-stale" fi # (c) Added lines in the diff vs base are ASCII-only (L3 PURITY preview). NONASCII="$(git diff "$BASE_REF"...HEAD 2>/dev/null | grep -n '^+' | grep -P '[^\x00-\x7F]' | head -5 || true)"