From 182c767c9c474adf227ad589014227ffb0e9e658 Mon Sep 17 00:00:00 2001 From: Joey Chilson Date: Mon, 19 Oct 2026 16:19:12 -0500 Subject: [PATCH 1/2] Tell time in the status text by the minute the status is as of verdict, fit, line, limit_text and stale were given the caller's unrounded now while text used status.at, so one answer could count from two clocks. Every caller now passes status.at, and short uses each moment's stored horizon instead of working out soon itself. --- CHANGELOG.md | 6 ++++++ src/main.rs | 11 +++++------ src/mcp.rs | 9 ++++----- src/status/text.rs | 12 ++++++------ tests/cli.rs | 29 +++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bc44a4..d770405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ read in the same words. `check_limits`' verdict on the first account says how to fix it, as the others do, not only that its limits can't be read. `status` writes $1,000 or more with a comma, as `statusline` does. +- **`check_limits`' verdict, `statusline`, and a limit's line in + `explain_usage` and `usage --limit`** count time from the minute the + status is as of, as `status` does. Late in a minute, `check_limits` + could say a limit resets "14:00 (in 1h 59m)" above a line that says + "14:00 (in 2h)", and `statusline` could say "out in 59m" while `status` + said "13:00 (in 1h)". ## 0.1.4 diff --git a/src/main.rs b/src/main.rs index 9b6b738..0518fb7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -397,7 +397,7 @@ fn answer(command: Command, json: bool, home: &Path, data: &Path) -> Result Some(status::fit(first, hours, now)?), + (Some(hours), Some(first)) => Some(status::fit(first, hours, status.at)?), _ => None, }; let text = status::text(&status, &shown, None); @@ -423,7 +423,7 @@ fn answer(command: Command, json: bool, home: &Path, data: &Path) -> Result Resu // An account no agent here is signed in to has nothing to fix. for account in &status.accounts { if account.state.why() != Some(status::Stale::SignedOut) - && let Some(stale) = status::stale(account, false, now) + && let Some(stale) = status::stale(account, false, status.at) { notes.push(format!("{}: {stale}.", account.full_title())); } @@ -835,8 +835,7 @@ fn guard( /// so it's quick; none when nothing's known. fn statusline(home: &Path, data: &Path, account: Option<&str>) -> Option { let db = db::open(data).ok()?; - let now = now(); - let status = status::status(&db, now).ok()?; + let status = status::status(&db, now()).ok()?; let id = match account { // A line is better than none: of several named, the first. Some(name) => Some(status::named(&status, name).ok()?.first()?.id.clone()), @@ -852,7 +851,7 @@ fn statusline(home: &Path, data: &Path, account: Option<&str>) -> Option .iter() .find(|account| account.in_use && !account.hidden)?, }; - Some(status::line(account, now)) + Some(status::line(account, status.at)) } /// The account of the session a harness names in the JSON it writes to a diff --git a/src/mcp.rs b/src/mcp.rs index c43967e..616719c 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -476,8 +476,7 @@ fn check_limits(server: &Server, arguments: Value) -> Result { all: bool, } let input: Input = input(arguments)?; - let now = crate::now(); - let status = status::status(&server.db, now)?; + let status = status::status(&server.db, crate::now())?; let mine = server.caller.account(&server.db, &server.home, &status)?; let yours = mine.map(|mine| mine.id.as_str()); let shown: Vec<&status::Account> = status @@ -503,12 +502,12 @@ fn check_limits(server: &Server, arguments: Value) -> Result { return Ok("No account's limits have been read yet: no agent here is signed in to one Turnscope reads.".to_owned()); }; let fit = match input.hours { - Some(hours) => format!("{}\n", status::fit(first, hours, now)?), + Some(hours) => format!("{}\n", status::fit(first, hours, status.at)?), None => String::new(), }; Ok(format!( "{fit}{}\n\n{}", - status::verdict(first, now), + status::verdict(first, status.at), status::text(&status, &shown, yours) )) } @@ -608,7 +607,7 @@ fn explain_usage(server: &Server, arguments: Value) -> Result { }, ); if let Some(limit) = limit { - out += &format!("{}\n", status::limit_text(limit, now)); + out += &format!("{}\n", status::limit_text(limit, status.at)); } if !used.rows.is_empty() { // Shares are known only over a limit's own window. diff --git a/src/status/text.rs b/src/status/text.rs index ab68236..3237686 100644 --- a/src/status/text.rs +++ b/src/status/text.rs @@ -2,7 +2,7 @@ //! each account and limit as lines, whether more hours of work fit, and the //! one sentence that leads an answer. The app says it in its own words. -use super::{Account, Limit, Outlook, Spread, Stale, State, Status, Unknown}; +use super::{Account, Horizon, Limit, Moment, Outlook, Spread, Stale, State, Status, Unknown}; use crate::agents; use crate::time::{self, HOUR, MINUTE, when}; @@ -414,14 +414,14 @@ fn short(limit: &Limit, now: i64) -> String { None => format!("{}%", limit.left_percent), }; // Soon, as a countdown; later, as the clock says it. - let at = |at: i64| match at - now < 3 * HOUR { - true => format!("in {}", time::span(at - now)), - false => time::clock(at, now), + let at = |moment: Moment| match moment.horizon { + Horizon::Soon => format!("in {}", time::span(moment.at - now)), + _ => time::clock(moment.at, now), }; match limit.outlook { - Outlook::UsedUp { back: Some(back) } => format!("{name} used up, back {}", at(back.at)), + Outlook::UsedUp { back: Some(back) } => format!("{name} used up, back {}", at(back)), Outlook::UsedUp { back: None } => format!("{name} used up"), - Outlook::RunsOut { likely, .. } => format!("{name} {left}, out {}", at(likely.at)), + Outlook::RunsOut { likely, .. } => format!("{name} {left}, out {}", at(likely)), _ => format!("{name} {left}"), } } diff --git a/tests/cli.rs b/tests/cli.rs index c0403f3..dff3a5b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -440,6 +440,35 @@ fn a_status_line_says_the_account_of_the_session_it_is_given() { ); } +#[test] +fn an_answer_tells_time_by_the_minute_its_status_is_as_of() { + let data = store(); + // 40 seconds into the minute; Claude's 5 hours runs out at 13:00 and + // resets at 14:00. + let late = (NOW + 40_000).to_string(); + let mut mcp = command(data.path(), &["mcp"]) + .env("TURNSCOPE_NOW", &late) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let request = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_limits","arguments":{"account":"claude"}}}"#; + writeln!(mcp.stdin.take().unwrap(), "{request}").unwrap(); + let reply: serde_json::Value = + serde_json::from_slice(&mcp.wait_with_output().unwrap().stdout).unwrap(); + let text = reply["result"]["content"][0]["text"].as_str().unwrap(); + // In the verdict, and in the 5 hours' own line. + assert_eq!(text.matches("resets 14:00 (in 2h)").count(), 2, "{text}"); + let line = command(data.path(), &["statusline", "--account", "claude"]) + .env("TURNSCOPE_NOW", &late) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&line.stdout).trim(), + "Claude Max · 5h 25%, out in 1h · weekly 56%" + ); +} + #[test] fn an_account_with_no_read_for_half_an_hour_shows_as_of_its_last() { let data = store(); From de89f76232733c993f00d3c2ae3cb51a42cbcc02 Mon Sep 17 00:00:00 2001 From: Joey Chilson Date: Mon, 19 Oct 2026 16:22:44 -0500 Subject: [PATCH 2/2] Leave explain_usage on its own clock Its first line counts its usage window from its unrounded now, so giving only its limit line status.at would make that one answer count from two clocks. --- CHANGELOG.md | 11 +++++------ src/mcp.rs | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d770405..fcec07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,11 @@ read in the same words. `check_limits`' verdict on the first account says how to fix it, as the others do, not only that its limits can't be read. `status` writes $1,000 or more with a comma, as `statusline` does. -- **`check_limits`' verdict, `statusline`, and a limit's line in - `explain_usage` and `usage --limit`** count time from the minute the - status is as of, as `status` does. Late in a minute, `check_limits` - could say a limit resets "14:00 (in 1h 59m)" above a line that says - "14:00 (in 2h)", and `statusline` could say "out in 59m" while `status` - said "13:00 (in 1h)". +- **`check_limits`' verdict, `statusline` and `usage --limit`'s first + line** count time from the minute the status is as of, as `status` + does. Late in a minute, `check_limits` could say a limit resets "14:00 + (in 1h 59m)" above a line that says "14:00 (in 2h)", and `statusline` + could say "out in 59m" while `status` said "13:00 (in 1h)". ## 0.1.4 diff --git a/src/mcp.rs b/src/mcp.rs index 616719c..56f5220 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -607,7 +607,7 @@ fn explain_usage(server: &Server, arguments: Value) -> Result { }, ); if let Some(limit) = limit { - out += &format!("{}\n", status::limit_text(limit, status.at)); + out += &format!("{}\n", status::limit_text(limit, now)); } if !used.rows.is_empty() { // Shares are known only over a limit's own window.