From 30ad3f11ed590ea7692b062d3612030c4adad8cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:50:40 +0700 Subject: [PATCH 1/2] tri worktrees: no `mine` label; the newest commit's age and subject instead, and a KEEP_ACTIVE verdict before REMOVABLE (Closes #11081) `tri worktrees` printed `mine` for every checkout when the caller's own path and the tree's path were both outside a scratchpad, so another session's lanes (commit subjects "widgets: ...", one committed seconds earlier) were listed as the caller's. Only the REMOVABLE rule kept them from a removal plan. The label is gone, with session_of that fed it and its four tests. specs/tri/worktrees/reap.t27 is extended, not duplicated: - verdict KEEP_ACTIVE (7, VERDICTS 8): the newest commit is younger than ACTIVE_MINUTES = 60, or a file in the tree changed in that time (the glue asks `find -mmin`, only for a tree a merged PR would make removable), or the commit time cannot be read. It is decided after the pull request facts and before the path fact, so no PR stays KEEP_NO_PR and an open one KEEP_OPEN_PR, and a tree whose PR merged but which a session still commits in is no longer one `git commit` from a removal line. - log_time and subject_from read `git log -1 --format=%ct%x09%s`; age_of reuses lanes' age_minutes and AGE_UNKNOWN by `use` (no second copy of either); active, age_count and age_unit decide the verdict and the unit the glue prints. - 5 tests added (18 in all, 13 on master), at the edges 59 and 60 minutes, for an unreadable log and a future commit time, for 11 seconds old (KEEP_ACTIVE, no removal line) and 60 minutes old (REMOVABLE as before). Negative control: 60 -> 6 in ACTIVE_MINUTES fails 2 of them. The exhaustive verdict test now also ranges over the new fact. The listing prints, for every tree it shows, "newest commit ago: "; the plan prints the same, the number of unpushed commits, and also the KEEP_ACTIVE rows, so a protected tree is visible. Run once on the machine this was written on, the plan listed 4 REMOVABLE trees (newest commits 8 and 9 days old) and 1 KEEP_ACTIVE tree whose PR is merged or closed and whose newest commit was 48 minutes old. Hand-written Rust in cli/tri/src/trees.rs: 25 added, 59 deleted (session_of, `here`, `mine`, session_tests). The tool card's --reap-plan doc and the clap doc it was read from are updated together. gen/rust/tri/worktrees/reap.rs is `t27c gen-rust` output. The seals of reap.t27 and of the tool card are not re-saved here: t27c's test run needs the Zig cache, which the sandbox this was written in cannot write, and a seal that says `tests: blocked` would be a false one. Closes #11081 Co-Authored-By: Claude Sonnet 5.5 --- cli/tri/src/trees.rs | 84 ++++------- gen/rust/tri/worktrees/reap.rs | 84 ++++++++++- specs/tools/tri/worktrees.t27 | 4 +- specs/tri/worktrees/reap.t27 | 262 +++++++++++++++++++++++++++------ 4 files changed, 327 insertions(+), 107 deletions(-) diff --git a/cli/tri/src/trees.rs b/cli/tri/src/trees.rs index afad6a06d5..b5d062897d 100644 --- a/cli/tri/src/trees.rs +++ b/cli/tri/src/trees.rs @@ -150,21 +150,6 @@ pub fn parse_porcelain(text: &str) -> Vec { out } -/// The session a scratchpad path belongs to, if it is under one. -/// -/// Scratch worktrees live under `.../claude-501///...`. Grouping by -/// that segment is what turns "120 trees" into "96 of them are somebody else's". -pub fn session_of(path: &str) -> Option<&str> { - let mut parts = path.split('/'); - while let Some(p) = parts.next() { - if p.starts_with("claude-") { - parts.next()?; // the project segment - return parts.next().filter(|s| s.len() >= 32); - } - } - None -} - /// Kilobytes to gibibytes, for printing only. pub fn gib(kb: u64) -> f64 { kb as f64 / 1_048_576.0 @@ -241,6 +226,19 @@ fn du_kb(path: &str) -> Option { .ok() } +/// The newest commit of a tree as `git log -1 --format=%ct%x09%s` prints it, and its age in minutes (reap.t27): what a reader has to go on to tell whose work a tree is. A path says nothing of it, and the `mine` label that guessed from the path is gone. +fn newest(path: &str) -> (&'static str, u64) { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let log: &'static str = git_in(path, &["log", "-1", "--format=%ct%x09%s"]).unwrap_or_default().leak(); + (log, reap::age_of(now, log)) +} + +/// That evidence as one phrase: the age, and the first 60 characters of the subject, quoted. +fn said((log, age): (&'static str, u64)) -> String { + let when = if age == reap::AGE_UNKNOWN { "time unreadable".to_string() } else { format!("{}{} ago", reap::age_count(age), reap::age_unit(age)) }; + format!("newest commit {when}: {:?}", log[reap::subject_from(log)..].chars().take(60).collect::()) +} + #[derive(Args)] pub struct Worktrees { /// Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the @@ -250,7 +248,7 @@ pub struct Worktrees { /// Print every tree, not only those holding work or unreadable. #[arg(long)] all: bool, - /// Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, and no live pid holds its lock. Still deletes nothing. + /// Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, no live pid holds its lock, and neither its newest commit nor a file in it is younger than 60 minutes (such a tree is KEEP_ACTIVE and is printed too). Each row shows the unpushed commits and the newest commit's age and subject. Still deletes nothing. #[arg(long)] reap_plan: bool, } @@ -267,16 +265,19 @@ fn reap_plan(trees: &[Tree], all: bool) { let pid = reap::lock_pid(t.locked.clone().unwrap_or_default().leak()); let gone = pid != 0 && Command::new("kill").args(["-0", &pid.to_string()]).output().map_or(false, |o| String::from_utf8_lossy(&o.stderr).contains("No such process")); let lock = reap::lock_state(t.locked.is_some(), pid, !gone); - let judge = |pr| reap::verdict(i == 0, dirty, lock, pr, reap::plain(path)); - let mut v = judge(reap::PR_MERGED); + let last = newest(path); + let judge = |pr, busy| reap::verdict(i == 0, dirty, lock, pr, reap::plain(path), busy); + let mut v = judge(reap::PR_MERGED, false); if v == reap::V_REMOVABLE { let url = format!("{}{branch}", reap::PULLS_URL); let pulls = if reap::plain(branch) { crate::payout::sh("curl", &["-fsSL", "--max-time", "30", "-H", "@-", &url], &auth) } else { "" }; - v = judge(reap::pr_state(serde_json::from_str::(pulls).ok().and_then(|j| serde_json::to_string_pretty(&j).ok()).unwrap_or_default().leak())); + let touched = Command::new("find").args([path, "-path", &format!("{path}/target"), "-prune", "-o", "-type", "f", "-mmin", &format!("-{}", reap::ACTIVE_MINUTES), "-print", "-quit"]).output().map_or(true, |o| !o.stdout.is_empty()); + v = judge(reap::pr_state(serde_json::from_str::(pulls).ok().and_then(|j| serde_json::to_string_pretty(&j).ok()).unwrap_or_default().leak()), reap::active(last.1, touched)); } counts[v as usize] += 1; - if v == reap::V_REMOVABLE || all { - println!(" {:14} {:28.28} {branch:34.34} {}", reap::verdict_name(v), path.rsplit('/').next().unwrap_or(path), reap::why(v)); + if v == reap::V_REMOVABLE || v == reap::V_KEEP_ACTIVE || all { + let ahead = match holding_of(t) { Holding::Work { commits, .. } => commits.to_string(), Holding::Clean { .. } => "0".into(), Holding::Unknown(_) => "?".into() }; + println!(" {:14} {:28.28} {branch:34.34} {ahead} unpushed, {} {}", reap::verdict_name(v), path.rsplit('/').next().unwrap_or(path), said(last), reap::why(v)); } if v == reap::V_REMOVABLE { println!(" {}{main}{}{} {path}", reap::REMOVE_HEAD, reap::REMOVE_MID, reap::remove_flags(lock)); @@ -295,9 +296,6 @@ pub fn run(a: &Worktrees) -> Result<()> { String::from_utf8_lossy(&out.stdout).to_string() }; let trees = parse_porcelain(&porcelain); - let here = std::env::current_dir() - .ok() - .and_then(|p| session_of(&p.to_string_lossy()).map(str::to_string)); let mut work = 0usize; let mut unknown = 0usize; @@ -318,7 +316,6 @@ pub fn run(a: &Worktrees) -> Result<()> { Holding::Unknown(_) => unknown += 1, Holding::Work { .. } => work += 1, } - let mine = session_of(&t.path) == here.as_deref(); let show = a.all || h.holds_work(); let (tk, ok) = if a.size { let target = du_kb(&format!("{}/target", t.path)).unwrap_or(0); @@ -366,15 +363,15 @@ pub fn run(a: &Worktrees) -> Result<()> { } Holding::Unknown(r) => format!("UNREADABLE -- {r}"), }; - let tag = if mine { "mine " } else { "other" }; + let seen = said(newest(&t.path)); if a.size { println!( - " {tag} {name:28.28} {branch:34.34} {state} [{:.1} GiB regenerable, {:.1} GiB not]", + " {name:28.28} {branch:34.34} {state} [{:.1} GiB regenerable, {:.1} GiB not] {seen}", gib(tk), gib(ok) ); } else { - println!(" {tag} {name:28.28} {branch:34.34} {state}"); + println!(" {name:28.28} {branch:34.34} {state} {seen}"); } } @@ -535,37 +532,6 @@ mod holding_tests { } } -#[cfg(test)] -mod session_tests { - use super::*; - - #[test] - fn a_scratch_path_names_its_session() { - let p = "/private/tmp/claude-501/-Users-x-PROJECTS/9ba6bf38-825b-45d0-a349-e87b252815cd/wt-shell"; - assert_eq!(session_of(p), Some("9ba6bf38-825b-45d0-a349-e87b252815cd")); - } - - #[test] - fn a_path_outside_a_scratchpad_names_none() { - assert_eq!(session_of("/Users/x/Desktop/PROJECTS/CLAUDE/t27"), None); - assert_eq!(session_of("/private/tmp/t27-brkstack"), None); - } - - #[test] - fn a_short_segment_is_not_a_session_id() { - // Guards against reading the next path component as an id whatever it is. - assert_eq!(session_of("/private/tmp/claude-501/proj/short/wt"), None); - } - - #[test] - fn two_paths_in_one_session_group_together() { - let a = "/private/tmp/claude-501/p/6d5ee66e-7a77-440d-bb58-d33bbe1e1558/scratchpad/CP"; - let b = "/private/tmp/claude-501/p/6d5ee66e-7a77-440d-bb58-d33bbe1e1558/scratchpad/hunt"; - assert_eq!(session_of(a), session_of(b)); - assert!(session_of(a).is_some()); - } -} - #[cfg(test)] mod gib_tests { use super::*; diff --git a/gen/rust/tri/worktrees/reap.rs b/gen/rust/tri/worktrees/reap.rs index 00a0844bf1..2a2e0c8599 100644 --- a/gen/rust/tri/worktrees/reap.rs +++ b/gen/rust/tri/worktrees/reap.rs @@ -1,6 +1,6 @@ // Generated from .t27 spec // DO NOT EDIT — generated by t27c -// NOT LOWERED BY THIS BACKEND: 13 test(s), 1 invariant(s), 0 function(s) with no body. +// NOT LOWERED BY THIS BACKEND: 18 test(s), 1 invariant(s), 0 function(s) with no body. // This backend emits declarations only. The spec's checks live in // the Zig and Verilog outputs; do not read this file as verified. @@ -40,7 +40,11 @@ pub const V_KEEP_NO_PR: u8 = 5; pub const V_KEEP_ODD_PATH: u8 = 6; -pub const VERDICTS: u8 = 7; +pub const V_KEEP_ACTIVE: u8 = 7; + +pub const VERDICTS: u8 = 8; + +pub const ACTIVE_MINUTES: u64 = 60; pub fn only_target(status: &'static str) -> bool { let mut k: usize = 0; @@ -122,7 +126,57 @@ pub fn pr_state(body: &'static str) -> u8 { return seen; } -pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 { +pub fn log_time(log: &'static str) -> u64 { + let mut e: usize = 0; + while (((e < log.len()) && (log.as_bytes()[(e) as usize] >= 48)) && (log.as_bytes()[(e) as usize] <= 57)) { + e = (e + 1); + } + if ((e == 0) || ((e < log.len()) && (log.as_bytes()[(e) as usize] != 9))) { + return 0; + } + return digits(log, 0, e); +} + +pub fn subject_from(log: &'static str) -> usize { + let mut k: usize = 0; + while ((k < log.len()) && (log.as_bytes()[(k) as usize] != 9)) { + k = (k + 1); + } + if (k < log.len()) { + return (k + 1); + } + return k; +} + +pub fn age_of(now: u64, log: &'static str) -> u64 { + return age_minutes(now, log_time(log)); +} + +pub fn active(age: u64, touched: bool) -> bool { + return ((touched || (age == AGE_UNKNOWN)) || (age < ACTIVE_MINUTES)); +} + +pub fn age_count(age: u64) -> u64 { + if (age < 60) { + return age; + } + if (age < 1440) { + return (age / 60); + } + return (age / 1440); +} + +pub fn age_unit(age: u64) -> &'static str { + if (age < 60) { + return "m"; + } + if (age < 1440) { + return "h"; + } + return "d"; +} + +pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool, busy: bool) -> u8 { if main { return V_KEEP_MAIN; } @@ -138,6 +192,9 @@ pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 { if ((pr != PR_MERGED) && (pr != PR_CLOSED)) { return V_KEEP_NO_PR; } + if busy { + return V_KEEP_ACTIVE; + } if (path_ok == false) { return V_KEEP_ODD_PATH; } @@ -170,12 +227,15 @@ pub fn verdict_name(v: u8) -> &'static str { if (v == V_KEEP_NO_PR) { return "KEEP_NO_PR"; } + if (v == V_KEEP_ACTIVE) { + return "KEEP_ACTIVE"; + } return "KEEP_ODD_PATH"; } pub fn why(v: u8) -> &'static str { if (v == V_REMOVABLE) { - return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock"; + return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock, nothing in it is recent"; } if (v == V_KEEP_MAIN) { return "the main checkout is never removed"; @@ -192,6 +252,9 @@ pub fn why(v: u8) -> &'static str { if (v == V_KEEP_NO_PR) { return "no merged or closed pull request was found for its branch"; } + if (v == V_KEEP_ACTIVE) { + return "its newest commit or one of its files is younger than the active window: someone may be working in it"; + } return "its path has a character the printed line would need quoted for"; } @@ -209,6 +272,10 @@ pub const OPEN_THEN_MERGED: &'static str = "[\n {\n \"merged_at\": null,\n pub const CLOSED_THEN_MERGED: &'static str = "[\n {\n \"merged_at\": null,\n \"number\": 5,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 6,\n \"state\": \"closed\"\n }\n]"; +pub const LOG: &'static str = "1760000000\twidgets: the newest commit"; + +pub const NOW: u64 = 1760000011; + pub fn key_at(text: &'static str, from: usize, stop: usize, ind: usize, key: &'static str) -> usize { let mut at: usize = from; while ((at < stop) && (at < text.len())) { @@ -325,6 +392,15 @@ pub fn value_to(text: &'static str, v: usize) -> usize { return e; } +pub const AGE_UNKNOWN: u64 = 4294967295; + +pub fn age_minutes(now: u64, then: u64) -> u64 { + if ((then == 0) || (then > now)) { + return AGE_UNKNOWN; + } + return ((now - then) / 60); +} + pub fn branch_from(w: &'static str, at: usize) -> usize { let stop: usize = record_end(w, at); let mut k: usize = at; diff --git a/specs/tools/tri/worktrees.t27 b/specs/tools/tri/worktrees.t27 index 1182c3fab1..0c80f61cf3 100644 --- a/specs/tools/tri/worktrees.t27 +++ b/specs/tools/tri/worktrees.t27 @@ -2,7 +2,7 @@ ; specs/tools/tri/worktrees.t27 -- tool tri/worktrees, the `tri worktrees` command of the tri CLI ; Source of truth for the tool card on t27.ai (#/tools?tool=tri/worktrees). ; Extracted from the clap #[derive(Subcommand)] enums in cli/tri/src (witness source-parse: -; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492; the --reap-plan arg was added from its /// doc in cli/tri/src/trees.rs with gHashTag/t27#10805). +; cargo was not run, the enum and its /// docs were read at commit 3cb7a4352492; the --reap-plan arg was added from its /// doc in cli/tri/src/trees.rs with gHashTag/t27#10805 and its doc re-read with gHashTag/t27#11081). ; Layer 5 of the ladder Specs -> Skills -> Crons -> Agents -> Tools. ASCII only (L3). ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -26,7 +26,7 @@ pub const ABOUT_SOURCE : str = "clap /// doc on Commands::Worktrees (cli/tri/src pub const ACTIONS : [0]str = []; pub const ACTIONS_ABOUT : [0]str = []; ; Top-level arguments/flags of the command itself, ": doc". -pub const ARGS : [3]str = ["--size: Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the holdings answer is the one that decides anything.", "--all: Print every tree, not only those holding work or unreadable.", "--reap-plan: Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, and no live pid holds its lock. Still deletes nothing."]; +pub const ARGS : [3]str = ["--size: Also measure disk usage. Off by default: `du` over 120 checkouts is slow, and the holdings answer is the one that decides anything.", "--all: Print every tree, not only those holding work or unreadable.", "--reap-plan: Print the `git worktree remove` line of every checkout that is REMOVABLE (rules: specs/tri/worktrees/reap.t27): its PR is merged or closed (asked of GitHub once per branch), it holds nothing but the untracked target link, no live pid holds its lock, and neither its newest commit nor a file in it is younger than 60 minutes (such a tree is KEEP_ACTIVE and is printed too). Each row shows the unpushed commits and the newest commit's age and subject. Still deletes nothing."]; ; Agent letters that own this command, only where a source line names it (AGENTS_NOTE cites it). pub const AGENTS : [2]str = ["T", "W"]; pub const AGENTS_NOTE : str = "docs/agents/AGENTS_ALPHABET.md \"Who holds a tool, and who holds a skill\" binds every tool to the Queen (T). Domain lead W: ABOUT is \"What the checkouts on this disk are holding. Deletes nothing\", and trees.rs explains \"a worktree is exactly where another session's uncommitted work lives ... 96 belong to one other session's scratchpad\" -- the per-session working state W's cell workflow runs in."; diff --git a/specs/tri/worktrees/reap.t27 b/specs/tri/worktrees/reap.t27 index f328d96d66..e2d306636f 100644 --- a/specs/tri/worktrees/reap.t27 +++ b/specs/tri/worktrees/reap.t27 @@ -10,7 +10,7 @@ ; pull request would make removable), calls the functions below and prints what they return. It never ; removes anything: it prints `git -C
worktree remove `, REMOVE_HEAD, ; the main checkout, REMOVE_MID, remove_flags(lock), the path. -; A checkout is judged by five facts, in this order, and the first that applies decides: +; A checkout is judged by six facts, in this order, and the first that applies decides: ; main the first checkout of `git worktree list` is the clone itself: never removed. ; dirty `git status --porcelain` prints anything but the untracked `target` link (`only_target`), ; or git could not read the tree: it holds work, or it is unknown, and unknown is not clean. @@ -20,8 +20,18 @@ ; pr the pull requests whose head is the branch (`pr_state`): open keeps it, merged or closed ; lets it go. No pull request, a detached tree, an unreadable answer, a failed request: NONE, ; and NONE keeps it. A tree is never removable on missing data. +; active the newest commit (`git log -1`) is younger than ACTIVE_MINUTES, or a file of the tree changed in +; that time (the glue asks `find -mmin`, only for a tree a merged pull request would make removable): +; someone may be working in it. A commit time nobody could read is not old. It comes after `pr`, so a +; tree with no pull request still says KEEP_NO_PR and an open one KEEP_OPEN_PR; it is the last word +; before REMOVABLE (gHashTag/t27#11081: a tree whose pull request had merged was one `git commit` from +; looking removable while its session still worked in it). ; path the printed line is typed into a terminal: a path with a character the shell reads ; (space, quote, `;`, `$`) is kept, not quoted by guesswork. +; The listing and the plan claim no tree for the caller. The old `mine` label guessed from the path, and a path says nothing +; about whose commits a tree holds. They print the evidence instead, for every tree: the age and the subject of its +; newest commit (`git log -1 --format=%ct%x09%s`, read here by log_time, subject_from and age_of), and in the +; plan the number of unpushed commits, so a reader or a loop can recognise a lane by what it is committing. ; The answer of GitHub is read as serde_json::to_string_pretty prints it: an array, a pull request ; ` {` ... ` }` at indent 2, its keys sorted at 4, the nested objects (head, user, milestone) deeper. ; Only the keys at indent 4 count: a milestone's own "state" is not the pull request's. The line @@ -42,6 +52,8 @@ use tri::lab::receipt::object_end; use tri::lanes::lanes::next_pr; use tri::lanes::lanes::starts; use tri::lanes::lanes::digits; +use tri::lanes::lanes::age_minutes; +use tri::lanes::lanes::AGE_UNKNOWN; // The pull requests whose head is a branch of this repository; the glue appends the branch name. pub const PULLS_URL : str = "https://api.github.com/repos/gHashTag/t27/pulls?state=all&per_page=10&head=gHashTag:"; @@ -69,9 +81,14 @@ pub const V_KEEP_LOCKED : u8 = 3; pub const V_KEEP_OPEN_PR : u8 = 4; pub const V_KEEP_NO_PR : u8 = 5; pub const V_KEEP_ODD_PATH : u8 = 6; -pub const VERDICTS : u8 = 7; +pub const V_KEEP_ACTIVE : u8 = 7; +pub const VERDICTS : u8 = 8; -invariant the_verdicts_are_numbered { assert V_REMOVABLE == 0 and V_KEEP_ODD_PATH == 6 and VERDICTS == 7 and PR_CLOSED == 3 and LOCK_UNKNOWN == 3; } +// A tree is somebody's while its newest commit, or one of its files, is younger than this. The age of a +// commit with no readable time is lanes' AGE_UNKNOWN, and it is never old. +pub const ACTIVE_MINUTES : u64 = 60; + +invariant the_verdicts_are_numbered { assert V_REMOVABLE == 0 and V_KEEP_ODD_PATH == 6 and V_KEEP_ACTIVE == 7 and VERDICTS == 8 and PR_CLOSED == 3 and LOCK_UNKNOWN == 3; } /// Does `git status --porcelain` print nothing but the untracked `target` link (or directory)? /// Empty is clean too. Every other entry, staged, modified, deleted or untracked, is work. @@ -148,15 +165,65 @@ pub fn pr_state(body: string) -> u8 { return seen; } +/// The commit time `git log -1 --format=%ct%x09%s` printed first, in seconds since 1970: the digits up +/// to the tab, or to the end of the text (git_in trims the tab of an empty subject). 0 when the text +/// does not start so. +pub fn log_time(log: string) -> u64 { + var e : usize = 0; + while (e < log.len and log[e] >= 48 and log[e] <= 57) { + e = e + 1; + } + if (e == 0 or (e < log.len and log[e] != 9)) { return 0; } + return digits(log, 0, e); +} + +/// Where the subject starts in that text: after the first tab, or at log.len when there is none. +pub fn subject_from(log: string) -> usize { + var k : usize = 0; + while (k < log.len and log[k] != 9) { + k = k + 1; + } + if (k < log.len) { return k + 1; } + return k; +} + +/// Whole minutes from the newest commit of `log` to `now`, by lanes' age_minutes: AGE_UNKNOWN when the +/// log holds no readable time or the time lies in the future (a skewed clock). That is not old. +pub fn age_of(now: u64, log: string) -> u64 { + return age_minutes(now, log_time(log)); +} + +/// Is the tree somebody's right now: its newest commit is younger than ACTIVE_MINUTES (or its age is +/// unknown), or one of its files changed in that time (`touched`, which the glue reads off `find -mmin`). +pub fn active(age: u64, touched: bool) -> bool { + return touched or age == AGE_UNKNOWN or age < ACTIVE_MINUTES; +} + +/// An age in minutes in the unit that fits it, in two parts so the glue can print ``: +/// minutes under an hour, hours under a day, days after. AGE_UNKNOWN is the glue's to word. +pub fn age_count(age: u64) -> u64 { + if (age < 60) { return age; } + if (age < 1440) { return age / 60; } + return age / 1440; +} + +pub fn age_unit(age: u64) -> string { + if (age < 60) { return "m"; } + if (age < 1440) { return "h"; } + return "d"; +} + /// The verdict on one checkout from its facts: is it the main checkout, does it hold work (or could -/// not be read), who holds its lock, what GitHub says of its branch, is its path plain. The first -/// fact that keeps it decides. A lock or a pull request state this code does not know keeps it too. -pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool) -> u8 { +/// not be read), who holds its lock, what GitHub says of its branch, is somebody working in it now +/// (`busy`, from active), is its path plain. The first fact that keeps it decides. A lock or a pull +/// request state this code does not know keeps it too. +pub fn verdict(main: bool, dirty: bool, lock: u8, pr: u8, path_ok: bool, busy: bool) -> u8 { if (main) { return V_KEEP_MAIN; } if (dirty) { return V_KEEP_DIRTY; } if (lock != LOCK_NONE and lock != LOCK_DEAD) { return V_KEEP_LOCKED; } if (pr == PR_OPEN) { return V_KEEP_OPEN_PR; } if (pr != PR_MERGED and pr != PR_CLOSED) { return V_KEEP_NO_PR; } + if (busy) { return V_KEEP_ACTIVE; } if (path_ok == false) { return V_KEEP_ODD_PATH; } return V_REMOVABLE; } @@ -175,16 +242,18 @@ pub fn verdict_name(v: u8) -> string { if (v == V_KEEP_LOCKED) { return "KEEP_LOCKED"; } if (v == V_KEEP_OPEN_PR) { return "KEEP_OPEN_PR"; } if (v == V_KEEP_NO_PR) { return "KEEP_NO_PR"; } + if (v == V_KEEP_ACTIVE) { return "KEEP_ACTIVE"; } return "KEEP_ODD_PATH"; } pub fn why(v: u8) -> string { - if (v == V_REMOVABLE) { return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock"; } + if (v == V_REMOVABLE) { return "its PR is merged or closed, it holds nothing but the target link, no live process holds its lock, nothing in it is recent"; } if (v == V_KEEP_MAIN) { return "the main checkout is never removed"; } if (v == V_KEEP_DIRTY) { return "it holds work, or git could not read it"; } if (v == V_KEEP_LOCKED) { return "a live process, or a lock with no pid to check, holds it"; } if (v == V_KEEP_OPEN_PR) { return "its pull request is still open"; } if (v == V_KEEP_NO_PR) { return "no merged or closed pull request was found for its branch"; } + if (v == V_KEEP_ACTIVE) { return "its newest commit or one of its files is younger than the active window: someone may be working in it"; } return "its path has a character the printed line would need quoted for"; } @@ -202,6 +271,10 @@ const OPEN_THEN_MERGED : string = "[\n {\n \"merged_at\": null,\n \"numbe const CLOSED_THEN_MERGED : string = "[\n {\n \"merged_at\": null,\n \"number\": 5,\n \"state\": \"closed\"\n },\n {\n \"merged_at\": \"2026-10-01T00:00:00Z\",\n \"number\": 6,\n \"state\": \"closed\"\n }\n]"; +// A newest commit as `git log -1 --format=%ct%x09%s` prints it: the commit time, a tab, the subject. NOW is 11 seconds later. +const LOG : string = "1760000000\twidgets: the newest commit"; +const NOW : u64 = 1760000011; + // Scenario: a tree holding nothing but the untracked target link is clean; anything else is work. test only_the_target_link_is_clean { assert only_target("") == true; @@ -297,43 +370,138 @@ test a_nested_state_is_not_the_pull_requests { // Scenario: each verdict, with the facts that give it. test each_verdict_has_its_case { - assert verdict(false, false, LOCK_NONE, PR_MERGED, true) == V_REMOVABLE; - assert verdict(false, false, LOCK_NONE, PR_CLOSED, true) == V_REMOVABLE; - assert verdict(false, false, LOCK_DEAD, PR_MERGED, true) == V_REMOVABLE; - assert verdict(true, false, LOCK_NONE, PR_MERGED, true) == V_KEEP_MAIN; - assert verdict(false, true, LOCK_NONE, PR_MERGED, true) == V_KEEP_DIRTY; - assert verdict(false, false, LOCK_LIVE, PR_MERGED, true) == V_KEEP_LOCKED; - assert verdict(false, false, LOCK_UNKNOWN, PR_MERGED, true) == V_KEEP_LOCKED; - assert verdict(false, false, LOCK_NONE, PR_OPEN, true) == V_KEEP_OPEN_PR; - assert verdict(false, false, LOCK_NONE, PR_NONE, true) == V_KEEP_NO_PR; - assert verdict(false, false, LOCK_NONE, PR_MERGED, false) == V_KEEP_ODD_PATH; -} - -// Scenario: the first fact that keeps a tree decides, so the reason shown is the gravest one. + assert verdict(false, false, LOCK_NONE, PR_MERGED, true, false) == V_REMOVABLE; + assert verdict(false, false, LOCK_NONE, PR_CLOSED, true, false) == V_REMOVABLE; + assert verdict(false, false, LOCK_DEAD, PR_MERGED, true, false) == V_REMOVABLE; + assert verdict(true, false, LOCK_NONE, PR_MERGED, true, false) == V_KEEP_MAIN; + assert verdict(false, true, LOCK_NONE, PR_MERGED, true, false) == V_KEEP_DIRTY; + assert verdict(false, false, LOCK_LIVE, PR_MERGED, true, false) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_UNKNOWN, PR_MERGED, true, false) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_NONE, PR_OPEN, true, false) == V_KEEP_OPEN_PR; + assert verdict(false, false, LOCK_NONE, PR_NONE, true, false) == V_KEEP_NO_PR; + assert verdict(false, false, LOCK_NONE, PR_MERGED, false, false) == V_KEEP_ODD_PATH; + assert verdict(false, false, LOCK_NONE, PR_MERGED, true, true) == V_KEEP_ACTIVE; + assert verdict(false, false, LOCK_NONE, PR_CLOSED, true, true) == V_KEEP_ACTIVE; +} + +// Scenario: the first fact that keeps a tree decides, so the reason shown is the gravest one. A tree that is +// active keeps the verdict it had before there was one unless a pull request merged or closed would have +// let it go: no pull request stays KEEP_NO_PR and an open one KEEP_OPEN_PR, however fresh the commit. test the_first_keeping_fact_decides { - assert verdict(true, true, LOCK_LIVE, PR_OPEN, false) == V_KEEP_MAIN; - assert verdict(false, true, LOCK_LIVE, PR_OPEN, false) == V_KEEP_DIRTY; - assert verdict(false, false, LOCK_LIVE, PR_OPEN, false) == V_KEEP_LOCKED; - assert verdict(false, false, LOCK_NONE, PR_OPEN, false) == V_KEEP_OPEN_PR; - assert verdict(false, false, LOCK_NONE, PR_NONE, false) == V_KEEP_NO_PR; - assert verdict(false, false, LOCK_DEAD, PR_OPEN, true) == V_KEEP_OPEN_PR; + assert verdict(true, true, LOCK_LIVE, PR_OPEN, false, true) == V_KEEP_MAIN; + assert verdict(false, true, LOCK_LIVE, PR_OPEN, false, true) == V_KEEP_DIRTY; + assert verdict(false, false, LOCK_LIVE, PR_OPEN, false, true) == V_KEEP_LOCKED; + assert verdict(false, false, LOCK_NONE, PR_OPEN, false, true) == V_KEEP_OPEN_PR; + assert verdict(false, false, LOCK_NONE, PR_NONE, false, true) == V_KEEP_NO_PR; + assert verdict(false, false, LOCK_NONE, PR_NONE, true, true) == V_KEEP_NO_PR; + assert verdict(false, false, LOCK_DEAD, PR_OPEN, true, true) == V_KEEP_OPEN_PR; + assert verdict(false, false, LOCK_NONE, PR_MERGED, false, true) == V_KEEP_ACTIVE; + assert verdict(false, false, LOCK_DEAD, PR_CLOSED, false, true) == V_KEEP_ACTIVE; + assert verdict(false, false, LOCK_NONE, PR_MERGED, false, false) == V_KEEP_ODD_PATH; } // Scenario (negative control): a merged branch whose tree holds one stray file is kept, and a clean tree // with no PR data is kept. The same facts with the stray file gone and the answer read are removable. test a_tree_is_never_removable_on_missing_data { - assert verdict(false, only_target(" M a.rs") == false, LOCK_NONE, PR_MERGED, true) == V_KEEP_DIRTY; - assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(""), true) == V_KEEP_NO_PR; - assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state("{\n \"message\": \"API rate limit exceeded\"\n}"), true) == V_KEEP_NO_PR; - assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(MERGED), true) == V_REMOVABLE; - assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), true), pr_state(MERGED), true) == V_KEEP_LOCKED; - assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), false), pr_state(MERGED), true) == V_REMOVABLE; - assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid("locked by hand"), false), pr_state(MERGED), true) == V_KEEP_LOCKED; + assert verdict(false, only_target(" M a.rs") == false, LOCK_NONE, PR_MERGED, true, false) == V_KEEP_DIRTY; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(""), true, false) == V_KEEP_NO_PR; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state("{\n \"message\": \"API rate limit exceeded\"\n}"), true, false) == V_KEEP_NO_PR; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(MERGED), true, false) == V_REMOVABLE; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), true), pr_state(MERGED), true, false) == V_KEEP_LOCKED; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid(AGENT_LOCK), false), pr_state(MERGED), true, false) == V_REMOVABLE; + assert verdict(false, only_target("?? target") == false, lock_state(true, lock_pid("locked by hand"), false), pr_state(MERGED), true, false) == V_KEEP_LOCKED; + assert verdict(false, only_target("?? target") == false, LOCK_NONE, pr_state(MERGED), true, active(age_of(NOW, ""), false)) == V_KEEP_ACTIVE; +} + +// Scenario: the log of the newest commit, as `git log -1 --format=%ct%x09%s` prints it and git_in trims it. +test the_newest_commit_is_read_from_its_log { + assert log_time(LOG) == 1760000000; + assert log_time("1760000000") == 1760000000; + assert log_time("1760000000\t") == 1760000000; + assert log_time("1760000000\ta\tb") == 1760000000; + assert log_time("") == 0; + assert log_time("\tsubject") == 0; + assert log_time("abc\tsubject") == 0; + assert log_time("17600x0000\tsubject") == 0; + assert log_time("1760000000 subject") == 0; + assert log_time("1760000000x") == 0; + assert log_time("99999999999999999999\tsubject") == 0; + assert subject_from(LOG) == 11; + assert subject_from("1760000000") == 10; + assert subject_from("1760000000\t") == 11; + assert subject_from("1760000000\ta\tb") == 11; + assert subject_from("") == 0; + assert LOG.len - subject_from(LOG) == 26; +} + +// Scenario: the age of the newest commit, in whole minutes. A log with no readable time, and a time from +// the future, are AGE_UNKNOWN: unknown is not old. +test the_age_of_the_newest_commit { + assert age_of(NOW, LOG) == 0; + assert age_of(1760000000 + 59, LOG) == 0; + assert age_of(1760000000 + 60, LOG) == 1; + assert age_of(1760000000 + 3540, LOG) == 59; + assert age_of(1760000000 + 3599, LOG) == 59; + assert age_of(1760000000 + 3600, LOG) == 60; + assert age_of(1760000000 + 3 * 3600, LOG) == 180; + assert age_of(1759999999, LOG) == AGE_UNKNOWN; + assert age_of(0, LOG) == AGE_UNKNOWN; + assert age_of(NOW, "") == AGE_UNKNOWN; + assert age_of(NOW, "no time\tsubject") == AGE_UNKNOWN; + assert age_of(NOW, "0\tsubject") == AGE_UNKNOWN; +} + +// Scenario: a tree is active while its newest commit is younger than 60 minutes, at both edges: 59 minutes +// is active and 60 minutes is not. A file changed in that time keeps it too, and an age nobody could read +// is not old. (Negative control: with 6 in place of 60 in ACTIVE_MINUTES the 59-minute asserts fail.) +test a_tree_is_active_for_the_first_hour { + assert active(age_of(1760000000 + 11, LOG), false) == true; + assert active(age_of(1760000000 + 59 * 60, LOG), false) == true; + assert active(age_of(1760000000 + 59 * 60 + 59, LOG), false) == true; + assert active(age_of(1760000000 + 60 * 60, LOG), false) == false; + assert active(age_of(1760000000 + 60 * 60 + 1, LOG), false) == false; + assert active(age_of(1760000000 + 3 * 3600, LOG), false) == false; + assert active(age_of(1760000000 + 3 * 3600, LOG), true) == true; + assert active(age_of(1760000000 + 60 * 60, LOG), true) == true; + assert active(age_of(NOW, ""), false) == true; + assert active(0, false) == true; + assert active(59, false) == true; + assert active(60, false) == false; + assert active(AGE_UNKNOWN, false) == true; + assert active(ACTIVE_MINUTES - 1, false) == true; + assert active(ACTIVE_MINUTES, false) == false; +} + +// Scenario: a tree whose newest commit is 11 seconds old is KEEP_ACTIVE and gets no removal line, though its +// PR is merged; the same tree 60 minutes later is REMOVABLE as before; and one file changed in the last hour +// keeps a tree whose newest commit is old. +test a_merged_tree_is_removable_only_when_nothing_in_it_is_recent { + const dirty : bool = only_target("?? target") == false; + assert verdict(false, dirty, LOCK_NONE, pr_state(MERGED), plain("/w/agent-a1"), active(age_of(1760000000 + 11, LOG), false)) == V_KEEP_ACTIVE; + assert verdict(false, dirty, LOCK_NONE, pr_state(MERGED), plain("/w/agent-a1"), active(age_of(1760000000 + 59 * 60, LOG), false)) == V_KEEP_ACTIVE; + assert verdict(false, dirty, LOCK_NONE, pr_state(MERGED), plain("/w/agent-a1"), active(age_of(1760000000 + 60 * 60, LOG), false)) == V_REMOVABLE; + assert verdict(false, dirty, LOCK_NONE, pr_state(MERGED), plain("/w/agent-a1"), active(age_of(1760000000 + 3 * 3600, LOG), false)) == V_REMOVABLE; + assert verdict(false, dirty, LOCK_NONE, pr_state(MERGED), plain("/w/agent-a1"), active(age_of(1760000000 + 3 * 3600, LOG), true)) == V_KEEP_ACTIVE; + assert verdict(false, dirty, LOCK_NONE, pr_state(""), plain("/w/agent-a1"), active(age_of(1760000000 + 11, LOG), false)) == V_KEEP_NO_PR; +} + +// Scenario: an age in minutes is said in one unit, and the edges fall on the unit's boundary. +test an_age_is_said_in_one_unit { + assert age_count(0) == 0 and span_is(age_unit(0), 0, 1, "m"); + assert age_count(11) == 11 and span_is(age_unit(11), 0, 1, "m"); + assert age_count(59) == 59 and span_is(age_unit(59), 0, 1, "m"); + assert age_count(60) == 1 and span_is(age_unit(60), 0, 1, "h"); + assert age_count(180) == 3 and span_is(age_unit(180), 0, 1, "h"); + assert age_count(1439) == 23 and span_is(age_unit(1439), 0, 1, "h"); + assert age_count(1440) == 1 and span_is(age_unit(1440), 0, 1, "d"); + assert age_count(1440 * 25) == 25 and span_is(age_unit(1440 * 25), 0, 1, "d"); + assert age_unit(11).len == 1 and age_unit(60).len == 1 and age_unit(1440).len == 1; } // Scenario: over every combination of facts, including a lock state and a PR state nobody defined, a // tree is REMOVABLE exactly when it is not main, not dirty, locked by nobody or a dead pid, its PR -// merged or closed, and its path plain. +// merged or closed, nobody is working in it, and its path plain. When everything else would let it go +// and it is busy, the verdict is KEEP_ACTIVE, whatever its path. test removable_exactly_when_every_fact_allows { var m : u8 = 0; while (m < 2) { @@ -345,13 +513,21 @@ test removable_exactly_when_every_fact_allows { while (p < 6) { var o : u8 = 0; while (o < 2) { - const v : u8 = verdict(m == 1, d == 1, l, p, o == 1); - const allowed : bool = m == 0 and d == 0 and (l == LOCK_NONE or l == LOCK_DEAD) and (p == PR_MERGED or p == PR_CLOSED) and o == 1; - if (allowed) { - assert v == V_REMOVABLE; - } else { - assert v != V_REMOVABLE; - assert v < VERDICTS; + var b : u8 = 0; + while (b < 2) { + const v : u8 = verdict(m == 1, d == 1, l, p, o == 1, b == 1); + const free : bool = m == 0 and d == 0 and (l == LOCK_NONE or l == LOCK_DEAD) and (p == PR_MERGED or p == PR_CLOSED); + const allowed : bool = free and o == 1 and b == 0; + if (allowed) { + assert v == V_REMOVABLE; + } else { + assert v != V_REMOVABLE; + assert v < VERDICTS; + } + if (free and b == 1) { + assert v == V_KEEP_ACTIVE; + } + b = b + 1; } o = o + 1; } @@ -387,7 +563,9 @@ test the_words { assert span_is(verdict_name(V_KEEP_OPEN_PR), 0, 12, "KEEP_OPEN_PR"); assert span_is(verdict_name(V_KEEP_NO_PR), 0, 10, "KEEP_NO_PR"); assert span_is(verdict_name(V_KEEP_ODD_PATH), 0, 13, "KEEP_ODD_PATH"); + assert span_is(verdict_name(V_KEEP_ACTIVE), 0, 11, "KEEP_ACTIVE"); + assert verdict_name(V_KEEP_ACTIVE).len == 11; assert why(V_REMOVABLE)[0] == 105 and why(V_KEEP_MAIN)[4] == 109 and why(V_KEEP_DIRTY)[0] == 105; assert why(V_KEEP_LOCKED)[0] == 97 and why(V_KEEP_OPEN_PR)[0] == 105 and why(V_KEEP_NO_PR)[0] == 110; - assert why(V_KEEP_ODD_PATH)[0] == 105; + assert why(V_KEEP_ODD_PATH)[0] == 105 and why(V_KEEP_ACTIVE)[0] == 105 and why(V_KEEP_ACTIVE)[4] == 110; } From f5d16886ac6536d9e94148a3255a6df0537a50da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:52:51 +0700 Subject: [PATCH 2/2] tri worktrees: no mine label, evidence columns and KEEP_ACTIVE for recent commits (Closes #11081) Merge of master plus sealing, gates run on the merged tree with t27c from master: tests clean with vacuous passes 0, seal --verify MATCH, tri types ratchet CLEAN, dupe_scan failure set a subset of master's. Co-Authored-By: Claude Sonnet 5.5 --- .trinity/seals/tri_tool_tri_worktrees.json | 14 +++++++------- .trinity/seals/worktrees_TriWorktreeReap.json | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.trinity/seals/tri_tool_tri_worktrees.json b/.trinity/seals/tri_tool_tri_worktrees.json index e384b7d094..a752d80cdd 100644 --- a/.trinity/seals/tri_tool_tri_worktrees.json +++ b/.trinity/seals/tri_tool_tri_worktrees.json @@ -1,17 +1,17 @@ { - "built_by": "t27c-bootstrap@0.5.2+ea35d9b72", + "built_by": "t27c-bootstrap@0.5.2+0fba674ef", "closure": [], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:3730bc66d153d4e749ac0760d26c72e9dbd4ea8f5e0660f3efcecdc713c67869", - "gen_hash_rust": "sha256:5e6c679216c3e25f1065236b2dbdad7f610a520d04a8930ae59e697feeb6cb31", - "gen_hash_verilog": "sha256:cc44189cebda1719fc034c942a6fad895f65515b1dee20a33f219b8d338c1675", - "gen_hash_zig": "sha256:2bf37ef5bc6ec7f82534dd30e20dfc06d4001fffc022c1e5ad71dd4cb0737803", + "gen_hash_c": "sha256:40dcc9b0a2438bf3bba47c959a7cdf4202738509fa37ce7fefb385fe20eef0be", + "gen_hash_rust": "sha256:0a4f5618702ce154c4594a26c97d9108806001bf5408d1c0cf2b3bfcedcf561d", + "gen_hash_verilog": "sha256:1f5bad229733be1569904c6440c5a3757ac3e0dffcb7dcd0e327a173f985ef15", + "gen_hash_zig": "sha256:e733373b45fdf78eacaf573e888085c07a712beaff59f463e0f7f726a29f58f7", "module": "tool_tri_worktrees", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-19T03:52:00Z", + "sealed_at": "2026-10-19T16:52:32Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:a1192fdda1ff3eadeb5de28ae9d662ee7505545c70e62ca1d2d24d1d32b6d1cf", + "spec_hash": "sha256:cf6ed19b9fb1becd56aed48c55fa05a974b8de1a8aa5daa072cd3e1058d6ba53", "spec_path": "specs/tools/tri/worktrees.t27", "tests": { "failed": 0, diff --git a/.trinity/seals/worktrees_TriWorktreeReap.json b/.trinity/seals/worktrees_TriWorktreeReap.json index 2bd4fa8590..1e7681548c 100644 --- a/.trinity/seals/worktrees_TriWorktreeReap.json +++ b/.trinity/seals/worktrees_TriWorktreeReap.json @@ -1,5 +1,5 @@ { - "built_by": "t27c-bootstrap@0.5.2+ae19b93cd", + "built_by": "t27c-bootstrap@0.5.2+0fba674ef", "closure": [ { "spec": "specs/tri/lab/receipt.t27", @@ -11,23 +11,23 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:ef394961ea6804088b9a81da13baebb8f1cbfe6e1187e62e3f7122b17c234c4c", - "gen_hash_rust": "sha256:a2b4fae679bfa41d9985a8c72810452a1748b7a490f224082d1d07b617ba9bd5", - "gen_hash_verilog": "sha256:716b12e948192e930b1e51c9f46cf0a674355d58e9e87d00137b27cbdadab708", - "gen_hash_zig": "sha256:8096bea0fc9750a5617560324c12c28001d6204e2371f65177005bb33612153d", + "gen_hash_c": "sha256:75a06970b662574386d285cb20ca8f8b984f5fe4e7af197c2f5c89cec9aec6f5", + "gen_hash_rust": "sha256:0da0f61c8c7296e0ccbddc33b84b849416b1a69914be8183a497d8e62170c57f", + "gen_hash_verilog": "sha256:3d1d336fe1db38c17cac57ad121b77c6f3c95f767df55a08930677d08ab1d3e8", + "gen_hash_zig": "sha256:d645b923f86a3b8402558a82399ea7ac575be30653c9a39640c1fa5aca2d998c", "module": "TriWorktreeReap", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-19T06:24:35Z", + "sealed_at": "2026-10-19T16:52:26Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:e6181a331c212c15d5bd38a26ab84a2d0f8f5885e7fcf2ae686063d219311016", + "spec_hash": "sha256:1b0ccf0834b3f4133bf5185b3576149da39286b54bad1320318f991b70c05f41", "spec_path": "specs/tri/worktrees/reap.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 13, - "total": 13, + "passed": 18, + "total": 18, "vacuous": 0 }, "toolchain": {