From 0b4d7cf793a16a59047aed70debdee14f19f765f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:02:04 -0300 Subject: [PATCH] feat(routines): a daily summary from the crew's chief (spec 29.2) The chief gets a new tool, crew_activity: for the last 1 to 168 hours (24 by default) it reports, per bot, the tasks done, failed, expired, open and late, a short sample of the latest ones with what was asked and the result, what waits for the owner (approvals and questions) and the latest run of each routine. Only the chief may call it; the chats with the owner are not included. The crew's routines tab gets a "Daily summary" card: pick a time and turn it on to give the chief a routine that runs every day, with a request in the owner's language that tells it to use crew_activity and write a short summary. It is an ordinary routine afterwards: it shows in the list, can be changed, turned off or run now. --- app/src/features/routines/DailySummary.tsx | 107 +++++++ app/src/features/routines/RoutineList.tsx | 2 + .../features/routines/dailySummary.test.tsx | 80 +++++ app/src/i18n/en/routines.ts | 17 ++ app/src/i18n/es/routines.ts | 14 + app/src/i18n/pt-BR/routines.ts | 14 + crates/botloftd/src/tools/activity.rs | 287 ++++++++++++++++++ crates/botloftd/src/tools/calls.rs | 5 +- crates/botloftd/src/tools/catalog.rs | 1 + crates/botloftd/src/tools/mod.rs | 3 +- crates/botloftd/tests/crew_activity.rs | 153 ++++++++++ crates/botloftd/tests/mcp.rs | 3 +- docs/spec.md | 19 ++ 13 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 app/src/features/routines/DailySummary.tsx create mode 100644 app/src/features/routines/dailySummary.test.tsx create mode 100644 crates/botloftd/src/tools/activity.rs create mode 100644 crates/botloftd/tests/crew_activity.rs diff --git a/app/src/features/routines/DailySummary.tsx b/app/src/features/routines/DailySummary.tsx new file mode 100644 index 00000000..d08a0c8b --- /dev/null +++ b/app/src/features/routines/DailySummary.tsx @@ -0,0 +1,107 @@ +// The daily summary (spec 29.2): a routine for the crew's chief, which asks it +// each evening to tell the owner what the bots did. It is a routine like any +// other, shown below in the list; this card is the easy way to make it and to +// turn it on or off. The app knows it by the tool its request names. + +import { Newspaper } from "lucide-react"; +import { useState } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useT } from "../../i18n"; +import type { Crew } from "../../lib/protocol.gen"; +import { routinesOf } from "../../store/app"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { describeSchedule, systemZone } from "./describe"; +import { RoutineDialog } from "./RoutineDialog"; + +const TOOL = "crew_activity"; +const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7]; + +export function DailySummary({ crew }: { crew: Crew }) { + const t = useT(); + const s = t.routines.summary; + const api = useApi(); + const putRoutine = useApp((state) => state.putRoutine); + const chief = useApp((state) => (crew.leadBotId ? state.bots[crew.leadBotId] : undefined)); + const mine = useApp(useShallow((state) => (chief ? routinesOf(state, chief.id) : []))); + const existing = mine.find((routine) => routine.prompt.includes(TOOL)); + const [time, setTime] = useState("18:00"); + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(false); + if (!chief) { + return null; + } + + const run = async (action: () => Promise) => { + setBusy(true); + await attempt(s.failed, action); + setBusy(false); + }; + const create = () => + run(async () => + putRoutine( + await api.call("routines.create", { + botId: chief.id, + name: s.name, + prompt: s.prompt, + schedule: { kind: "weekly", days: EVERY_DAY, time }, + timezone: systemZone(), + overlap: "skip", + missed: "run_once", + }), + ), + ); + const toggle = (enabled: boolean) => + run(async () => { + if (existing) { + putRoutine(await api.call("routines.setEnabled", { routineId: existing.id, enabled })); + } + }); + + return ( +
+
+ +
+

{s.title}

+

{s.hint}

+
+
+ {existing ? ( +
+ + {existing.enabled ? s.on(describeSchedule(existing.schedule, t.routines.when)) : s.off} + + + +
+ ) : ( +
+ + +
+ )} + {editing && existing && ( + setEditing(false)} /> + )} +
+ ); +} diff --git a/app/src/features/routines/RoutineList.tsx b/app/src/features/routines/RoutineList.tsx index 9d105571..3b51aeb6 100644 --- a/app/src/features/routines/RoutineList.tsx +++ b/app/src/features/routines/RoutineList.tsx @@ -9,6 +9,7 @@ import type { Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { DailySummary } from "./DailySummary"; import { RoutineDialog } from "./RoutineDialog"; import { RoutineRow } from "./RoutineRow"; @@ -66,6 +67,7 @@ export function CrewRoutines({ crew }: { crew: Crew }) { }); return (
+ {byBot.length === 0 ? (

{r.crewEmpty} diff --git a/app/src/features/routines/dailySummary.test.tsx b/app/src/features/routines/dailySummary.test.tsx new file mode 100644 index 00000000..5206c8c9 --- /dev/null +++ b/app/src/features/routines/dailySummary.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { en } from "../../i18n/en"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openTab, renderApp } from "../../test/app"; + +afterEach(cleanup); + +const calls = (fake: FakeBotloft, method: string) => + fake.calls.filter((call) => call.method === method); + +/** A crew "Site" with its chief, open on the crew's routines. */ +async function openCrewRoutines(withChief = true) { + const fake = new FakeBotloft(); + if (withChief) { + await fake.call("crews.create", { + name: "Site", + lead: { name: "Chief", role: "Leads the crew", instructions: "Build it" }, + }); + } else { + fake.addCrew("Site"); + } + renderApp(fake); + await crewOpened("Site"); + openTab(/^Routines/); + return { fake }; +} + +describe("the daily summary", () => { + test("is offered on the crew's routines and turned on for the chief, every day", async () => { + const { fake } = await openCrewRoutines(); + const card = await screen.findByRole("region", { name: "Daily summary" }); + fireEvent.change(within(card).getByLabelText("Time"), { target: { value: "19:30" } }); + fireEvent.click(within(card).getByRole("button", { name: "Turn on" })); + + await waitFor(() => expect(calls(fake, "routines.create")).toHaveLength(1)); + const [create] = calls(fake, "routines.create"); + expect(create?.params).toMatchObject({ + name: "Daily summary", + schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "19:30" }, + overlap: "skip", + missed: "run_once", + }); + // It asks the chief to use the tool that tells it what the bots did. + const params = create?.params as { prompt: string; botId: string } | undefined; + expect(params?.prompt).toContain("crew_activity"); + const chief = (await fake.call("bots.list", {})).find((bot) => bot.name === "Chief"); + expect(params?.botId).toBe(chief?.id); + + expect(await within(card).findByText(/^On · /)).toBeDefined(); + expect(within(card).getByRole("button", { name: "Turn off" })).toBeDefined(); + }); + + test("can be turned off and on again, and shows in the list like any routine", async () => { + const { fake } = await openCrewRoutines(); + const card = await screen.findByRole("region", { name: "Daily summary" }); + fireEvent.click(within(card).getByRole("button", { name: "Turn on" })); + await within(card).findByText(/^On · /); + expect( + within(await screen.findByRole("list", { name: "Routines" })).getByText("Daily summary"), + ).toBeDefined(); + + fireEvent.click(within(card).getByRole("button", { name: "Turn off" })); + expect(await within(card).findByText("Off")).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Turn on" })); + await within(card).findByText(/^On · /); + expect(calls(fake, "routines.setEnabled").map((call) => call.params)).toEqual([ + expect.objectContaining({ enabled: false }), + expect.objectContaining({ enabled: true }), + ]); + // One routine, not a second one each time. + expect(calls(fake, "routines.create")).toHaveLength(1); + }); + + test("is not offered to a crew without a chief", async () => { + await openCrewRoutines(false); + await screen.findByText(en.routines.crewEmpty); + expect(screen.queryByRole("region", { name: "Daily summary" })).toBeNull(); + }); +}); diff --git a/app/src/i18n/en/routines.ts b/app/src/i18n/en/routines.ts index c06d4b98..9a67224c 100644 --- a/app/src/i18n/en/routines.ts +++ b/app/src/i18n/en/routines.ts @@ -105,6 +105,23 @@ export const routines = { signal_invalid: "A signal needs letters or digits, like report-ready.", }, }, + /** The daily summary the chief writes (spec 29.2), a routine made for the owner. */ + summary: { + title: "Daily summary", + hint: "Every day the chief tells you what each bot did, what is stuck and what is waiting for you.", + name: "Daily summary", + // Sent to the chief every day. It names the tool, which is also how the app + // recognizes this routine. + prompt: + "The day is ending. Use crew_activity (the last 24 hours) and write me a short summary of the day: what each bot finished, what is still open or late, what failed, and what is waiting for me (approvals and questions), the most important first. Plain words, no more than 12 lines. If nothing happened, say so in one line. Do not start new work and do not message other bots for this.", + time: "Time", + turnOn: "Turn on", + turnOff: "Turn off", + on: (when: string) => `On · ${when}`, + off: "Off", + change: "Change", + failed: "Could not change the daily summary", + }, removeTitle: (name: string) => `Delete ${name}?`, removeBody: "It stops running. What it already did stays in the chat.", failed: { diff --git a/app/src/i18n/es/routines.ts b/app/src/i18n/es/routines.ts index ff813f76..2e9e4885 100644 --- a/app/src/i18n/es/routines.ts +++ b/app/src/i18n/es/routines.ts @@ -106,6 +106,20 @@ export const routines: Messages["routines"] = { signal_invalid: "El aviso necesita letras o números, como informe-listo.", }, }, + summary: { + title: "Resumen diario", + hint: "Cada día el jefe te cuenta qué hizo cada bot, qué está parado y qué te está esperando.", + name: "Resumen diario", + prompt: + "El día se acaba. Usa crew_activity (las últimas 24 horas) y escríbeme un resumen corto del día: qué terminó cada bot, qué sigue abierto o atrasado, qué falló y qué me está esperando (aprobaciones y preguntas), lo más importante primero. Palabras sencillas, como máximo 12 líneas. Si no pasó nada, dilo en una línea. No empieces trabajo nuevo ni escribas a otros bots por esto.", + time: "Hora", + turnOn: "Activar", + turnOff: "Desactivar", + on: (when: string) => `Activado · ${when}`, + off: "Desactivado", + change: "Cambiar", + failed: "No se pudo cambiar el resumen diario", + }, removeTitle: (name: string) => `¿Eliminar ${name}?`, removeBody: "Deja de ejecutarse. Lo que ya hizo sigue en el chat.", failed: { diff --git a/app/src/i18n/pt-BR/routines.ts b/app/src/i18n/pt-BR/routines.ts index 21a3aff4..fbb4e9e8 100644 --- a/app/src/i18n/pt-BR/routines.ts +++ b/app/src/i18n/pt-BR/routines.ts @@ -106,6 +106,20 @@ export const routines: Messages["routines"] = { signal_invalid: "O aviso precisa de letras ou números, como relatorio-pronto.", }, }, + summary: { + title: "Resumo diário", + hint: "Todo dia o chefe conta o que cada bot fez, o que está parado e o que está esperando por você.", + name: "Resumo diário", + prompt: + "O dia está acabando. Use crew_activity (as últimas 24 horas) e escreva para mim um resumo curto do dia: o que cada bot terminou, o que ainda está aberto ou atrasado, o que deu errado e o que está esperando por mim (aprovações e perguntas), o mais importante primeiro. Palavras simples, no máximo 12 linhas. Se nada aconteceu, diga isso em uma linha. Não comece trabalho novo e não escreva para outros bots por causa disso.", + time: "Horário", + turnOn: "Ligar", + turnOff: "Desligar", + on: (when: string) => `Ligado · ${when}`, + off: "Desligado", + change: "Mudar", + failed: "Não foi possível mudar o resumo diário", + }, removeTitle: (name: string) => `Apagar ${name}?`, removeBody: "Ela para de rodar. O que ela já fez continua no chat.", failed: { diff --git a/crates/botloftd/src/tools/activity.rs b/crates/botloftd/src/tools/activity.rs new file mode 100644 index 00000000..6383f577 --- /dev/null +++ b/crates/botloftd/src/tools/activity.rs @@ -0,0 +1,287 @@ +//! `crew_activity` (spec 29.2): what the bots of a crew did lately, for the +//! chief to write the owner a summary. Counts and short excerpts of the +//! tasks the bots gave each other; never the chat with the owner. + +use botloft_core::ids::BotId; +use botloft_core::protocol::{Bot, BotsListParams, Task, TaskStatus}; +use botloft_store::TaskFilter; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::explain; +use crate::service::{bots, lead}; +use crate::state::Daemon; + +pub const CREW_ACTIVITY: &str = "crew_activity"; + +const HOURS_DEFAULT: u32 = 24; +const HOURS_MAX: u32 = 168; +/// Longest excerpt of a request or a result, in characters. +const EXCERPT_CHARS: usize = 400; +/// Tasks listed for each bot; the counts always cover all of them. +const TASKS_PER_BOT: usize = 6; +const ROUTINES_PER_BOT: usize = 5; +const HOUR_MS: i64 = 3_600_000; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Args { + #[serde(default)] + hours: Option, +} + +pub(super) fn tools() -> [Value; 1] { + [json!({ + "name": CREW_ACTIVITY, + "title": "Crew activity", + "description": "Only for the crew's chief. What each bot of the crew did in the last \ + hours (24 by default, up to 168): tasks finished, failed or expired, with what was \ + asked and the result in short; tasks still open and the ones past their deadline; \ + what is waiting for the owner (approvals and questions) and the latest run of each \ + routine. Use it to write the owner a summary. Texts are shortened, and the chats \ + with the owner are not included.", + "inputSchema": { + "type": "object", + "properties": { + "hours": { + "type": "integer", + "minimum": 1, + "maximum": HOURS_MAX, + "description": "How far back to look, in hours. 24 if left out.", + }, + }, + "additionalProperties": false, + }, + "annotations": { "readOnlyHint": true }, + })] +} + +pub(super) fn call(daemon: &Daemon, bot: &BotId, args: Args) -> Result { + let hours = args.hours.unwrap_or(HOURS_DEFAULT); + if !(1..=HOURS_MAX).contains(&hours) { + return Err(format!("hours must be between 1 and {HOURS_MAX}")); + } + let (crew, me) = bots::active(&daemon.store(), bot).map_err(explain)?; + if !lead::is_lead(&crew, &me.id) { + return Err( + "only the crew's chief can read the crew's activity; ask the chief with send_message" + .to_owned(), + ); + } + let crew_bots = bots::list( + daemon, + BotsListParams { + crew_id: Some(crew.id.clone()), + }, + ) + .map_err(explain)?; + let now = daemon.clock.now_ms(); + let since = now - i64::from(hours) * HOUR_MS; + + let store = daemon.store(); + let tasks = store + .tasks(TaskFilter { + crew: Some(&crew.id), + ..TaskFilter::default() + }) + .map_err(|err| explain(err.into()))?; + let handle_of = |id: &BotId| { + crew_bots + .iter() + .find(|other| &other.id == id) + .map_or_else(|| "another crew".to_owned(), |other| other.handle.clone()) + }; + + let mut totals = Totals::default(); + let mut report = Vec::new(); + for member in &crew_bots { + let mine: Vec<&Task> = tasks + .iter() + .filter(|task| task.assignee_bot_id == member.id) + .collect(); + let finished: Vec<&Task> = mine + .iter() + .copied() + .filter(|task| task.status != TaskStatus::Open && task.updated_at >= since) + .collect(); + let count = + |status: TaskStatus| finished.iter().filter(|task| task.status == status).count(); + let open = mine + .iter() + .filter(|task| task.status == TaskStatus::Open) + .count(); + let overdue = mine + .iter() + .filter(|task| task.status == TaskStatus::Open && task.deadline_at < now) + .count(); + let waiting_approvals = store + .pending_approvals(&member.id) + .map_or(0, |pending| pending.len()); + let waiting_questions = store.open_questions(&member.id).unwrap_or(0); + totals.add( + count(TaskStatus::Done), + count(TaskStatus::Failed), + count(TaskStatus::Expired), + open, + overdue, + waiting_approvals + waiting_questions, + ); + + let recent: Vec = finished + .iter() + .take(TASKS_PER_BOT) + .map(|task| { + let request = store + .task_request(&task.id) + .ok() + .flatten() + .map(|message| excerpt(&message.body)); + json!({ + "status": task.status, + "from": handle_of(&task.requester_bot_id), + "hours_ago": hours_ago(now, task.updated_at), + "request": request, + "result": task.result.as_deref().map(excerpt), + }) + }) + .collect(); + let routines: Vec = store + .routines(Some(&member.id), false) + .unwrap_or_default() + .iter() + .take(ROUTINES_PER_BOT) + .map(|routine| { + json!({ + "name": routine.name, + "on": routine.enabled, + "last_run": routine.last_run.as_ref().map(|run| json!({ + "status": run.status, + "hours_ago": hours_ago(now, run.created_at), + })), + }) + }) + .collect(); + + report.push(bot_report( + member, + json!({ + "done": count(TaskStatus::Done), + "failed": count(TaskStatus::Failed), + "expired": count(TaskStatus::Expired), + "open": open, + "overdue": overdue, + }), + recent, + json!({ "approvals": waiting_approvals, "questions": waiting_questions }), + routines, + )); + } + + Ok(json!({ + "crew": crew.name, + "window_hours": hours, + "totals": totals.json(), + "bots": report, + "note": "Counts cover everything in the window; `recent` lists only the latest few \ + tasks of each bot. Write the owner a short summary in their language and say \ + plainly what you could not see.", + })) +} + +fn bot_report( + member: &Bot, + tasks: Value, + recent: Vec, + waiting_for_owner: Value, + routines: Vec, +) -> Value { + json!({ + "handle": member.handle, + "name": member.name, + "role": member.role, + "state": member.state, + "tasks": tasks, + "recent": recent, + "waiting_for_owner": waiting_for_owner, + "routines": routines, + }) +} + +#[derive(Default)] +struct Totals { + done: usize, + failed: usize, + expired: usize, + open: usize, + overdue: usize, + waiting_for_owner: usize, +} + +impl Totals { + fn add( + &mut self, + done: usize, + failed: usize, + expired: usize, + open: usize, + overdue: usize, + waiting: usize, + ) { + self.done += done; + self.failed += failed; + self.expired += expired; + self.open += open; + self.overdue += overdue; + self.waiting_for_owner += waiting; + } + + fn json(&self) -> Value { + json!({ + "done": self.done, + "failed": self.failed, + "expired": self.expired, + "open": self.open, + "overdue": self.overdue, + "waiting_for_owner": self.waiting_for_owner, + }) + } +} + +/// The text cut to a length a summary can carry. +fn excerpt(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.chars().count() <= EXCERPT_CHARS { + return trimmed.to_owned(); + } + let cut: String = trimmed.chars().take(EXCERPT_CHARS).collect(); + format!("{}…", cut.trim_end()) +} + +/// Whole and tenth hours between `then` and `now`, never below zero. +fn hours_ago(now: i64, then: i64) -> f64 { + let tenths = ((now - then).max(0) * 10 + HOUR_MS / 2) / HOUR_MS; + tenths as f64 / 10.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_text_is_cut_and_marked() { + let long = "a".repeat(EXCERPT_CHARS + 50); + let cut = excerpt(&long); + assert_eq!(cut.chars().count(), EXCERPT_CHARS + 1); + assert!(cut.ends_with('…')); + assert_eq!(excerpt(" short "), "short"); + // Not in the middle of a multi-byte character. + assert!(excerpt(&"ã".repeat(EXCERPT_CHARS + 5)).ends_with('…')); + } + + #[test] + fn hours_ago_rounds_to_a_tenth() { + assert_eq!(hours_ago(HOUR_MS * 3, 0), 3.0); + assert_eq!(hours_ago(HOUR_MS * 3 / 2, 0), 1.5); + assert_eq!(hours_ago(0, HOUR_MS), 0.0); + } +} diff --git a/crates/botloftd/src/tools/calls.rs b/crates/botloftd/src/tools/calls.rs index 699a0a72..bef4bb68 100644 --- a/crates/botloftd/src/tools/calls.rs +++ b/crates/botloftd/src/tools/calls.rs @@ -19,7 +19,7 @@ use super::catalog::{COMPLETE_TASK, CREW_ROSTER, MY_TASKS, SEND_MESSAGE}; use super::question::{self, ASK_OWNER}; use super::share::{self, SHARE_FILE}; use super::signal::{self, SEND_SIGNAL}; -use super::{crew_reach, routine_change, templates}; +use super::{activity, crew_reach, routine_change, templates}; use crate::service::tasks::{self, BotMessage}; use crate::service::{ApiError, bots, lead}; use crate::state::Daemon; @@ -48,6 +48,9 @@ pub(super) fn call(daemon: &Daemon, bot: &BotId, params: &Value) -> Result parse(arguments).and_then(|args| question::ask(daemon, bot, args)), SEND_SIGNAL => parse(arguments).and_then(|args| signal::send(daemon, bot, args)), routine_change::MY_ROUTINES => routine_change::mine(daemon, bot), + activity::CREW_ACTIVITY => { + parse(arguments).and_then(|args| activity::call(daemon, bot, args)) + } templates::LIST_BOT_TEMPLATES => { parse(arguments).and_then(|args| templates::list(daemon, bot, args)) } diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 5f06b1e4..36c6bc23 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -33,6 +33,7 @@ pub fn tools() -> Value { .chain(super::routine_catalog::tools()) .chain([super::bot_change::tool(), super::crew_access::tool()]) .chain(super::templates::tools()) + .chain(super::activity::tools()) .chain(super::crew_reach::tools()) .chain(super::browser_catalog::tools()) .chain(super::desktop_catalog::tools()); diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index 2c822047..c2f350e0 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -6,6 +6,7 @@ //! Claude Code tries `server/discover` first and falls back to //! `initialize` when that fails, so both paths lead to the same tools. +mod activity; mod bot_change; mod browser; mod browser_args; @@ -58,7 +59,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles). schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ + suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The desktop_ tools read the apps open on \ the owner's own computer, app by app as the owner allows. The owner writes to you directly; messages from other bots and from Botloft start with \ diff --git a/crates/botloftd/tests/crew_activity.rs b/crates/botloftd/tests/crew_activity.rs new file mode 100644 index 00000000..39ff8dbe --- /dev/null +++ b/crates/botloftd/tests/crew_activity.rs @@ -0,0 +1,153 @@ +//! `crew_activity` (spec 29.2): the chief reads what the crew's bots did, +//! for the owner's daily summary; nobody else may. + +mod common; + +use botloft_core::protocol::BotState; +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; + +struct Site { + t: TestDaemon, + app: Client, + chief: Mcp, + writer: Mcp, +} + +/// A crew "Site" with its chief (Chefe) and a bot Writer, both ready. +async fn site() -> Site { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call( + "crews.create", + json!({ + "name": "Site", + "lead": { "name": "Chefe", "role": "Leads the crew", "instructions": "Build it" }, + }), + ) + .await + .expect("crew"); + let bots = app + .call("bots.list", json!({ "crewId": crew["id"] })) + .await + .expect("bots"); + let chief_bot = bots[0].clone(); + let process = t.process_of(&chief_bot).await; + t.until_state(&chief_bot, BotState::Idle).await; + let chief = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + let (_, _writer_process, writer) = ready_bot(&t, &mut app, &crew, "Writer").await; + Site { + t, + app, + chief, + writer, + } +} + +fn writer_of(report: &Value) -> &Value { + report["bots"] + .as_array() + .expect("bots") + .iter() + .find(|bot| bot["handle"] == "writer") + .expect("the writer is in the report") +} + +#[tokio::test] +async fn the_chief_reads_what_each_bot_did() { + let mut s = site().await; + let done = s + .chief + .tool( + "send_message", + json!({ "to": "@writer", "body": "Write the intro.", "kind": "task", "deadline_minutes": 30 }), + ) + .await + .expect("task"); + let still_open = s + .chief + .tool( + "send_message", + json!({ "to": "@writer", "body": "Write the outro.", "kind": "task", "deadline_minutes": 30 }), + ) + .await + .expect("second task"); + assert!(still_open["task_id"].is_string()); + s.writer + .tool( + "complete_task", + json!({ "task_id": done["task_id"], "result": "It is in shared/intro.md." }), + ) + .await + .expect("complete"); + + let report = s + .chief + .tool("crew_activity", json!({})) + .await + .expect("activity"); + assert_eq!(report["crew"], "Site"); + assert_eq!(report["window_hours"], 24); + assert_eq!(report["totals"]["done"], 1); + assert_eq!(report["totals"]["open"], 1); + assert_eq!(report["totals"]["failed"], 0); + assert_eq!(report["totals"]["overdue"], 0); + + let writer = writer_of(&report); + assert_eq!(writer["tasks"]["done"], 1); + assert_eq!(writer["tasks"]["open"], 1); + assert_eq!(writer["recent"][0]["status"], "done"); + assert_eq!(writer["recent"][0]["from"], "chefe"); + assert_eq!(writer["recent"][0]["request"], "Write the intro."); + assert_eq!(writer["recent"][0]["result"], "It is in shared/intro.md."); + assert_eq!(writer["waiting_for_owner"]["approvals"], 0); + assert_eq!(writer["waiting_for_owner"]["questions"], 0); + assert_eq!(writer["routines"], json!([])); + drop(s.app); + drop(s.t); +} + +#[tokio::test] +async fn what_waits_for_the_owner_is_counted() { + let mut s = site().await; + s.writer + .tool( + "ask_owner", + json!({ "question": "Which tone do you want?", "options": ["Warm", "Formal"] }), + ) + .await + .expect("question"); + let report = s + .chief + .tool("crew_activity", json!({ "hours": 1 })) + .await + .expect("activity"); + assert_eq!(report["window_hours"], 1); + assert_eq!(writer_of(&report)["waiting_for_owner"]["questions"], 1); + assert_eq!(report["totals"]["waiting_for_owner"], 1); + drop(s.app); +} + +#[tokio::test] +async fn only_the_chief_reads_it_and_the_window_has_limits() { + let mut s = site().await; + let refused = s + .writer + .tool("crew_activity", json!({})) + .await + .expect_err("a bot that is not the chief"); + assert!(refused.contains("only the crew's chief"), "{refused}"); + + for hours in [0, 169] { + let refused = s + .chief + .tool("crew_activity", json!({ "hours": hours })) + .await + .expect_err("outside the window"); + assert!(refused.contains("between 1 and 168"), "{refused}"); + } + drop(s.app); +} diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 32422718..7fac01a1 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -83,6 +83,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "ask_crew_access", "list_bot_templates", "get_bot_template", + "crew_activity", "crew_files", "read_crew_file", "write_crew_file", @@ -150,7 +151,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(40) + Some(41) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/docs/spec.md b/docs/spec.md index e139de4d..a0125a69 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -575,6 +575,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `effort?`, `reason` (até 1 000), `template?` (um id do catálogo, 26.4: com ele `role` e `instructions` ficam opcionais) | `created` e, criado, `handle`, `name`, `role`, `model`, `effort` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe | | `list_bot_templates` | `category?` | `templates`: `id`, `category`, `name`, `role` e `summary` de cada modelo de bot do catálogo (26). Só o chefe | | `get_bot_template` | `id` | a ficha de um modelo: os campos acima, `model`, `effort` e `instructions` (26.2). Só o chefe | +| `crew_activity` | `hours?` (1 a 168, 24 por padrão) | o que a crew fez na janela, por bot: contagens de tasks (`done`, `failed`, `expired`, `open`, `overdue`), uma amostra das últimas com o pedido e o resultado cortados, o que espera o dono e a última execução de cada rotina (29.2). Só o chefe | | `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) | | `ask_crew_access` | `crew` (nome da outra crew), `bot?` (handle de um bot dela), `access` (`talk`, `read`, `edit`), `why` (até 1 000) | `allowed` e, permitido, como usar; recusado ou sem resposta, o porquê (10.4) | | `crew_files`, `read_crew_file`, `write_crew_file` | `crew`, e `bot?`, `path` ou `path` e `content` | os arquivos de outra crew que o bot alcança, o texto de um, ou o aviso de que gravou (10.4) | @@ -1167,6 +1168,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito | | Abas nas mãos do dono com o Claude Code real | 21.10 | **A verificar**: com um bot no meio de uma tarefa num site, o dono assume, abre uma aba nova em outro endereço e devolve. O bot deve ler o aviso de que a aba mudou (no erro da tool que ia agir, ou no `browser_look`), ler a aba nova e seguir a tarefa nela ou abrir de novo a página de que precisa, sem agir às cegas. Recarregar sem assumir: o bot lê "The owner reloaded the page." e continua | manual (PR) | | Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito | +| Resumo diário com o Claude Code real | 29.2 | **A verificar**: numa equipe com um chefe e um ou dois bots que já fizeram tasks, ligar o resumo e rodar a rotina na hora. O chefe deve carregar `crew_activity` pelo `ToolSearch` (as tools do `botloft` chegam adiadas), escrever o resumo no idioma do dono em até 12 linhas, não começar trabalho novo nem escrever a outros bots, e a rotina deve fechar como `done` | manual (PR) | | Bot compartilha arquivos com `share_file` | 10, 8.4 | Ferramenta nossa, sem comportamento novo do Claude Code: a tool vem liberada por `--allowedTools mcp__botloft` e é carregada pelo `ToolSearch` como as outras. **Testado com 2.1.284** (daemon de dev, Haiku, modo Aceitar edições), só com a regra de 5.1 e a descrição da tool: pedido um relatório salvo na pasta de trabalho, o bot o escreveu e chamou `share_file` sozinho; pedido "me manda o relatório de novo", chamou de novo sem escrever o caminho na resposta. A primeira chamada veio com o caminho do Windows com `\` sem escape, que o próprio Claude Code recusa como JSON inválido; o bot repetiu certo. Por isso a descrição do campo pede barras normais. O conteúdo não foi para o log | feito | | Esforço em `-p` | 7.4 | Confirmado (`cli-reference`, `model-config`, `headless`): `--effort` aceita `low`, `medium`, `high`, `xhigh`, `max` (e `ultracode`, não usado), vale só para a sessão e não é guardado; os níveis dependem do modelo (Fable, Opus e Sonnet atuais têm os cinco; Opus 4.6 e Sonnet 4.6 não têm `xhigh`); sem escolha, o padrão é `high`, menos em Opus 5.5 e Sonnet 5.5 (`medium`) e Opus 4.7 (`xhigh`); `/effort ` funciona em `-p` e vale só para a sessão; as chaves `effortLevel` e `modelSettings` das settings não aceitam `max`; trocar o esforço no meio da sessão mantém o cache em Opus 5.5, Sonnet 5.5 e Fable 5.1 e o invalida nos outros (`prompt-caching`). **Testado com 2.1.284** (`--setting-sources project,local`, plano Max): `--effort max` e `xhigh` com `--model sonnet` são aplicados; um valor desconhecido só avisa no stderr e usa o padrão; `--resume` sem a flag volta ao padrão do modelo; sem a flag, Sonnet 5.5 e Opus 5.5 aplicam `medium` e Fable 5.1 `high`; Haiku 4.5 não tem níveis (o `initialize` do SDK lista `supportedEffortLevels` para os outros e nada para ele) e ignora a flag sem erro. **Haiku 5.5** (lido em `model-config` e na visão geral dos modelos em 2026-10-07, ainda não visto com o Claude Code real): o apelido `haiku` aponta para `claude-haiku-5-5` a partir do Claude Code 2.1.293 (antes disso, para o 4.5), com os cinco níveis, padrão `medium` e janela de 1 milhão de tokens; o app lê níveis e janela do que o Claude Code informa, então o mesmo código serve aos dois. `/effort low` mandado como mensagem troca na hora, com uma resposta sintética e sem custo, e o pedido de controle `apply_flag_settings {effortLevel}` também; o daemon reinicia o processo em vez disso. O que acontece com um nível que o modelo não tem (`xhigh` num modelo antigo) não foi visto: os apelidos do Botloft apontam para modelos com os cinco | feito | | Pedidos de controle no stdin | 9.2 | `getContextUsage()` é documentado no Agent SDK (`agent-sdk/typescript`), com `totalTokens`, `maxTokens`, `rawMaxTokens`, `percentage`, `autoCompactThreshold` e `isAutoCompactEnabled`; o formato no fio e `get_settings` **não são documentados**. **Testado com 2.1.284**: `{"type":"control_request","request_id","request":{"subtype":"get_context_usage"}}` é respondido com `control_response` (`subtype: "success"`, o mesmo `request_id`, `response`) em até 2,5 s, antes do primeiro turno, entre turnos e durante um turno, sem `system/init` nem `result`; `get_settings` responde `{effective, sources, applied: {model, effort, ...}}`, com `effort: null` em Haiku; um `subtype` desconhecido responde `subtype: "error"`. Numa sessão retomada, a primeira resposta levou uma vez mais de 15 s. **Pelo daemon de dev** (Haiku e Sonnet): o modelo e o esforço do modelo chegaram ao app antes do primeiro turno, e `--model sonnet --effort low` apareceu na linha de comando do bot depois de `bots.setEffort` | feito | @@ -2613,3 +2615,20 @@ Montar uma equipe do zero pede escolher cinco ou seis papéis, um por um, na Ag | Marco | O que entra | Teste | |---|---|---| | **TM1** Modelos | o passo de modelos em "Nova equipe", os 10 modelos, a criação em ordem e o aviso de falha parcial, três idiomas | `FakeBotloft`: a lista, o formulário preenchido, `crews.create` e um `catalog.add` por papel, e uma falha que não derruba a equipe; todo papel existe no catálogo; `pnpm check`. Manual (PR): criar uma equipe de 2 ou 3 modelos com o Claude Code real e dar ao chefe um pedido | + +### 29.2 Resumo diário + +O dono não precisa abrir cada bot para saber como foi o dia. O **resumo diário** é uma rotina (20) do chefe que, no fim do dia, lê o que a equipe fez e conta ao dono, em poucas linhas, no chat do chefe. + +- **A ferramenta `crew_activity`** (10) é do chefe, como `suggest_bot`: outro bot recebe um erro que o manda pedir ao chefe. Entrada: `hours?` (1 a 168, 24 por padrão). Devolve `crew`, `window_hours`, `totals` (`done`, `failed`, `expired`, `open`, `overdue`, `waiting_for_owner`) e, para cada bot da crew: `handle`, `name`, `role`, `state`, `tasks` (as mesmas contagens), `recent` (as últimas 6 tasks encerradas na janela: `status`, `from`, `hours_ago`, o pedido e o resultado, cada um cortado em 400 caracteres), `waiting_for_owner` (`approvals` e `questions` abertos) e `routines` (nome, se está ligada e a última execução). As contagens cobrem tudo na janela; `recent` é só uma amostra. A ferramenta só lê, não grava nada e não mostra as conversas do dono com os bots: só as tasks que os bots deram uns aos outros, que o chefe já veria como mensagens. +- **A rotina.** Na aba "Rotinas" da equipe, um cartão "Resumo diário" com um horário (18:00 por padrão) e "Ligar". Ligar cria, para o chefe, uma rotina "Resumo diário" **todo dia** nesse horário (fuso do computador), com sobreposição `skip` e horário perdido `run_once` (se o computador estava desligado, o resumo sai uma vez quando voltar). O pedido, escrito no idioma do dono, manda o chefe usar `crew_activity` nas últimas 24 horas e escrever um resumo curto (no máximo 12 linhas): o que cada bot terminou, o que está aberto ou atrasado, o que falhou e o que espera o dono, o mais importante primeiro; se nada aconteceu, uma linha; sem começar trabalho novo nem escrever a outros bots. O app reconhece essa rotina por o pedido citar `crew_activity`. +- **Depois de ligado,** o cartão mostra "Ligado · todo dia às 18:00" com "Desligar" (a rotina fica, desligada) e "Mudar" (o editor de rotinas de 20.9, para horário, dias ou o texto). É uma rotina comum: aparece na lista, roda pelo agendador, mostra "Rotina · Resumo diário" no chat e pode ser executada na hora. Uma equipe sem chefe não tem o cartão. +- **Custo.** Cada resumo é um turno do chefe, que gasta um pouco do plano do dono: o cartão não esconde isso, e desligar é um clique. +- **Privacidade.** Nada sai do computador por conta disso. A ferramenta devolve ao chefe só o que o chefe já alcança nas tasks da própria crew, e o log segue sem corpo de mensagem (13). +- **Fora desta etapa.** Um resumo por e-mail ou por um canal de fora; um resumo semanal; o resumo de todas as equipes numa só mensagem; escolher quais bots entram. + +| Marco | O que entra | Teste | +|---|---|---| +| **RD1** Ferramenta | `crew_activity`: contagens, amostra, esperas do dono, rotinas; só o chefe; limites da janela | `crew_activity.rs`: um chefe e um bot, uma task concluída e uma aberta; uma pergunta ao dono contada; outro bot e `hours` fora de 1 a 168 recusados; `mcp.rs` com a ferramenta na lista | +| **RD2** Cartão | o cartão "Resumo diário" na aba de rotinas da equipe, ligar, desligar, mudar, três idiomas | `FakeBotloft`: criar para o chefe todo dia no horário escolhido com o pedido que cita a ferramenta, desligar e ligar sem criar outra, equipe sem chefe sem o cartão; `pnpm check` | +| **RD3** Teste real | o resumo com o Claude Code real numa equipe de teste | **A verificar** (seção 19): o chefe chama `crew_activity`, escreve o resumo no idioma do dono e não começa trabalho novo; a rotina "agora" fecha como `done` |