/** * The `team` tool: the lead's round over the same cards `board` owns. * * Five actions, because they are the five a lead takes at the round: hand a card out, * offer it, take one back, take one itself, and ask a peer how its work is going. `assign` * sends the handoff **first** and sets the owner **second**: a card assigned without a * message is a card nobody knows about, while a message for a card that then changed is * one the peer can still claim. Either failure is reported with the card as it actually * is, so the lead's next call starts from the truth. `ask` is the one verb with no card * behind it, and the one that names nothing the board can answer. * * `claim` is the lead's own work, and it is the same store operation `board`'s claim * makes, so the lane rules, the WIP limit, and the offer record cleared are the store's — * not a second answer the round would keep in step by hand. Everything after the claim * (plan, contract, move, finish) is `board`'s, because that is the card's life and not * the round's. * * `assign`, `ask`, and `claim` join this session to the team once, because each is * answered by mail: `session-bus` records a sender and arms no watch, so without the join * a lead that only ever runs the round would have a reply nobody reports. `offer` and * `reclaim` do not, and need not: one is answered by a claim, the other by nothing. */ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Static } from "typebox"; import { type Card, cardRef, normalizeCardId } from "../../lib/board/card.ts"; import { indexOf } from "../../lib/board/links.ts"; import { type Actor, mayOp } from "../../lib/board/rules.ts"; import type { BoardSession } from "../../lib/board/session.ts"; import type { BoardStore } from "../../lib/board/store.ts"; import { messageOf } from "../../lib/errors.ts"; import { sanitizeUntrustedLine } from "../../lib/format.ts"; import type { DeliveryFailure, TeamJoin } from "../../lib/session-tool.ts"; import { failedResult, type ToolFailure, textResult } from "../../lib/tool-result.ts"; import { reuseText, toolDetails } from "../../lib/tui/chrome.ts"; import { paint } from "../../lib/tui/tokens.ts"; import { callLine, detailsOrLine, messageText, type ToolMessageResult } from "../../lib/tui/tool.ts"; import { plural } from "../../lib/board/round-text.ts"; import { strayField } from "../../lib/stray-fields.ts"; import { actorFor, askText, type Dispatch, DEFAULT_QUESTION, handoffText, notListening, offerText, ownerFor, sessionDispatch, } from "./dispatch.ts"; import { formatAsk, formatClaimed, formatHandoff, formatHandoffFailure, formatOffer, formatOfferFailure, formatRacedOffer, formatRacedAssignment, formatReclaimed, } from "./format.ts"; import { TeamParams } from "./schema.ts"; import { ACTION_FIELDS, normalizeTeamAction, TEAM_ACTIONS, TEAM_TOOL, type TeamAction, type TeamDetails, } from "./types.ts"; /** The parameters as the schema validates them. */ type TeamArgs = Static; /** One refusal: the reason for the model, the state for the renderer. */ function refusal(action: TeamAction, message: string, card?: Card): ToolFailure { return failedResult(`Error: ${message}`, { action, error: message, ...(card === undefined ? {} : { card }) }); } /** * The call line's subject: the verb, the card, and the peer it names. * * `assign` and `ask` are the two verbs that act on a peer, and `ask` is the one with no * card — so its subject is the peer alone, and a line that stopped at the verb would hide * the only thing the call was about. */ function callSubject(action: TeamAction, args: Record): string { const card = typeof args.card === "string" && args.card.trim() !== "" ? cardRef(args.card.trim()) : ""; const to = typeof args.to === "string" && args.to.trim() !== "" ? ` → ${args.to.trim()}` : ""; const peer = action === "assign" || action === "ask" ? to : ""; return card === "" ? `${action}${peer}` : `${action} ${card}${peer}`; } /** * The result's own lines, as the expanded block draws them. * * `format.ts` owns the wording, and the model reads it as the call's text, so the block * draws that text rather than a second copy of the sentences: a card's title and paths * reach a human in the words the tool already wrote. Both are a card's, so every line is * sanitized at the boundary before the theme paints it — and the indent `format.ts` gives * a continuation line is put back, because the sanitizer trims one and a title under the * sentence it belongs to is not a list item. */ function resultLines(theme: Theme, result: ToolMessageResult): string { return messageText(result) .split("\n") .map((line) => paint(theme, "toolOutput", `${continuationIndent(line)}${sanitizeUntrustedLine(line)}`)) .join("\n"); } /** The two-space indent a result's continuation line carries, and a first line does not. */ function continuationIndent(line: string): string { return line.startsWith(" ") ? " " : ""; } /** What the tool needs from the outside, so a suite drives it without a bus. */ export interface TeamToolsDeps { /** Test seam: the handoff transport. */ dispatch?: (ctx: Parameters[0]) => Dispatch; /** Test seam: the join a verb makes, so a suite records it instead of asking a bus. */ team?: TeamJoin; } export function registerTools(pi: ExtensionAPI, session: BoardSession, deps: TeamToolsDeps = {}): void { const dispatchFor = deps.dispatch ?? sessionDispatch; /** The join, built on the first verb that needs it: a round that never asks pays nothing. */ let joinBuilder: Promise | undefined; const teamFor = (): Promise => { if (deps.team !== undefined) return Promise.resolve(deps.team); joinBuilder ??= import("../../lib/session-tool.ts").then(({ createTeamJoin }) => createTeamJoin(pi)); return joinBuilder; }; /** * Put this session on the team, once, for the verbs a peer answers by mail. * * The same join `/board` and `board`'s claim make, asked again when the bus no longer holds * this session, so a lead that only ever runs the round is on the team under the label it * writes cards as rather than merely on the bus, where a send records a sender and arms no * watch. Loaded on demand, like the claim's join was, so registration stays as small as it is. */ const joinRound = async (ctx: ExtensionContext, label: string): Promise => { const { joinOnce } = await import("../../lib/board/join.ts"); await joinOnce(session, await teamFor(), ctx, label); }; pi.registerTool({ name: TEAM_TOOL, label: "Team", description: "Run a round of work over the shared board: `assign` sends a running session a handoff and then records the owner " + "(`to` is a teammate's label or id, as `/team` lists them); `offer` broadcasts an unclaimed card to every other " + "live session, then records the moment it was made — the lane and the owner stay put — so the first to claim " + "it takes it, and a lead can tell an offer nobody took from a card nobody has looked at; `reclaim` takes a card " + "back to Backlog and sends nothing, so it needs no team; `claim` takes one for yourself, and the card's plan, " + "contract, moves, and finish are `board`'s; `ask` sends one peer a question and reports what the bus did with " + "it — delivered, or waiting for that peer's next turn — so the answer arrives as mail and nothing waits on it. " + "Nothing here starts a peer's turn: a message waits in its inbox, and a peer that is listening reports it at " + "its next turn.", promptSnippet: "Run a round over the board: assign a card to a running session, offer one, reclaim one, claim one, or ask " + "one peer a question.", promptGuidelines: [ "Offer a card nobody has taken; assign one you mean a particular peer to hold; claim one you will take " + "yourself, then plan and record it with `board`.", "Ask a peer with `ask` when a card cannot answer the question: the round says what a session holds, not how " + "the work is going. The reply arrives as mail, so read the inbox before asking again.", "When a result says `Not listening`, say so: the message waits for that peer's next turn.", "Reclaim a card whose owner is gone or has gone quiet rather than editing someone else's work. A " + "`gone:` owner's process is not running, so what it held is anyone's to reclaim.", ], parameters: TeamParams, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false }, executionMode: "sequential", async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const args = params as TeamArgs; const action = normalizeTeamAction(args.action); if (!action) { return refusal("assign", `unknown action "${String(args.action)}"; use ${TEAM_ACTIONS.join(" or ")}.`); } // Before the store and before the card: a refused call has done nothing, so it // cannot have read a card or sent a message on its way to the refusal. const stray = strayField(action, args, ACTION_FIELDS); if (stray !== undefined) return refusal(action, stray); const me = await session.who(ctx, ctx.cwd); // The join every verb a peer answers by mail asks for, built once per call from the // name the reply line names — so the round never keeps a second copy of its label. const join = (): Promise => joinRound(ctx, me.label); try { // `ask` is the one verb with no card behind it, so it runs before the id and the store: // what it needs is this session's name, for the reply line, and the bus. if (action === "ask") return await askPeer(me, args.to, args.question, dispatchFor(ctx), join); const id = args.card === undefined ? undefined : normalizeCardId(args.card); if (id === undefined) { return refusal( action, args.card === undefined ? 'card is required: pass the id, e.g. "4".' : `"${String(args.card)}" is not a card id.`, ); } const store = await session.store(ctx.cwd); const now = session.now(); if (action === "reclaim") return await reclaimCard(store, me, id, now); if (action === "offer") return await offerCard(store, me, id, args.note, now, dispatchFor(ctx)); if (action === "claim") return await claimCard(store, me, id, now, join); const to = typeof args.to === "string" ? args.to.trim() : ""; if (to === "") return refusal("assign", 'assign needs a peer: pass `to`, e.g. "billing-core".'); return await assignCard(store, me, id, to, args.note, now, dispatchFor(ctx), join); } catch (error) { return refusal(action, messageOf(error)); } }, renderCall(args, theme, context) { const action = normalizeTeamAction(args.action) ?? "assign"; return callLine(theme, context.lastComponent, TEAM_TOOL, callSubject(action, args as Record)); }, renderResult(result, options, theme, context) { const text = reuseText(context?.lastComponent); const details = toolDetails(result.details); const outcome = detailsOrLine(theme, result, options.expanded, details); if ("line" in outcome) { text.setText(outcome.line); return text; } // A round's result is a card and a sentence about it, so the collapsed block is the one // summary line and expanding reveals the lines the model was handed. text.setText(options.expanded ? resultLines(theme, result) : summaryOf(outcome.details)); return text; }, }); } /** * `assign`: check the preconditions, send the handoff, then set the owner. * * The preconditions are checked with `mayOp`, the same decision the store's `applyOp` will * make — and with the store's own limits, so a configured WIP cap is the one the precheck * sees — so an assignment the rules would refuse never sends a message at all. A handoff * that landed is followed by the join, because the peer's next move is to answer it. */ async function assignCard( store: BoardStore, me: Actor, id: string, to: string, note: string | undefined, now: number, dispatch: Dispatch, join: () => Promise, ): Promise> | ToolFailure> { const card = await store.read(id); if (!card) return refusal("assign", `${cardRef(id)} does not exist.`); // A handoff to yourself is not a claim: it writes a message into your own inbox and // records you as the owner without the claim's next step. `claim` is that act. if (namesSelf(me, to)) return refusal("assign", `${cardRef(id)} cannot be handed to yourself: \`claim\` takes one.`, card); // Who `to` names, asked before the check and once: a card records the id the bus gave its // holder, and `holds` compares ids first, so an id the caller typed has to be resolved to // the peer it names rather than compared as a label. The same actor is what the write // records, so the check and the write cannot disagree about who `to` is. const peers = await dispatch.peers().catch(() => undefined); const target = actorFor(peers, to); // The board, not just the card: a card that waits on unfinished work, or a `plan` lane // with no room, is refused here, so a handoff for it is never sent. `mayOp` is the // store's own check without the write, and the board's settings are the store's, not a default. const refused = mayOp(card, { kind: "assign", to: target }, me, indexOf(await store.list()), store.config()); if (refused !== undefined) return refusal("assign", refused, card); let delivered: string[]; let failed: DeliveryFailure[]; try { const sent = await dispatch.send({ kind: "handoff", to, text: handoffText(card, to, store.config().lead, note) }); delivered = sent.delivered; failed = sent.failed; } catch (error) { return refusal("assign", formatHandoffFailure(card, to, messageOf(error)), card); } // The owner a handoff records: the resolved peer, whose id survives the peer renaming // itself, or the label a message file was written for when the bus could not say who that // is. The same read also says whether that peer will be woken by the message it just got. const applied = await store.mutate( id, { kind: "assign", to: target.id === undefined ? ownerFor(to, delivered) : target }, me, now, ); if (!applied.ok) return refusal("assign", formatRacedAssignment(applied.card ?? card, to, applied.error), applied.card ?? card); // A handoff is the lead telling a peer to work and ask it questions, so the same call that // hands the card over puts the lead on the team: without it the reply waits in an inbox no // turn reports, and `/team` lists the session that just assigned a card as not listening. await join(); const silent = notListening(peers, delivered); return textResult(formatHandoff(applied.card, to, delivered, silent, failed), { action: "assign", card: applied.card, assigned: applied.card.owner, delivered, ...(failed.length === 0 ? {} : { failed }), ...(silent.length === 0 ? {} : { notListening: silent }), }); } /** * `offer`: check the claim's preconditions, broadcast the card, then record the offer. * * The broadcast comes first and the record second, because a record of an offer nobody * received would be worse than an offer with no record: the card is unchanged by a failed * send, and the first peer to claim is what makes an offer into work. What the record * adds is the one thing an event cannot carry — when it was made — so a lead can tell an * offer nobody took from a card nobody has looked at. */ async function offerCard( store: BoardStore, me: Actor, id: string, note: string | undefined, now: number, dispatch: Dispatch, ): Promise> | ToolFailure> { const card = await store.read(id); if (!card) return refusal("offer", `${cardRef(id)} does not exist.`); // The board, not just the card: a card that waits on unfinished work, one somebody // holds, or a `plan` lane with no room is refused here, so a broadcast for it is never // sent. This is the offer's own rule, asked without the write. const refused = mayOp(card, { kind: "offer" }, me, indexOf(await store.list()), store.config()); if (refused !== undefined) return refusal("offer", refused, card); let delivered: string[]; let failed: DeliveryFailure[]; try { const sent = await dispatch.send({ kind: "event", to: "*", text: offerText(card, store.config().lead, note) }); delivered = sent.delivered; failed = sent.failed; } catch (error) { return refusal("offer", formatOfferFailure(card, messageOf(error)), card); } // The peers have the event, so a card that changed underneath is reported as raced // rather than refused: the message is already in their inboxes. The same holds for the // peer the broadcast missed — the offer is recorded, and the result names who did not hear. const recorded = await store.mutate(id, { kind: "offer" }, me, now); if (!recorded.ok) return refusal("offer", formatRacedOffer(recorded.card ?? card, recorded.error), recorded.card); const peers = await dispatch.peers().catch(() => undefined); const silent = notListening(peers, delivered); return textResult(formatOffer(recorded.card, delivered, silent, failed), { action: "offer", card: recorded.card, delivered, ...(failed.length === 0 ? {} : { failed }), ...(silent.length === 0 ? {} : { notListening: silent }), }); } /** Whether `to` names this session: its id, or its label ignoring case. */ function namesSelf(me: Actor, to: string): boolean { const target = to.trim(); return (me.id !== undefined && target === me.id) || target.toLowerCase() === me.label.toLowerCase(); } /** * `ask`: send one peer a question, and report what happened to it. * * The one verb that writes no card: there is nothing to check, nothing to own, and nothing * to wait on. What it does report is the same pair `assign` does — the labels a message file * was written for, and which of them will read it at their next turn instead of being * woken — because "delivered" and "delivered and read" are different answers to a lead * asking whether a peer heard. */ async function askPeer( me: Actor, rawTo: unknown, rawQuestion: unknown, dispatch: Dispatch, join: () => Promise, ): Promise> | ToolFailure> { const to = typeof rawTo === "string" ? rawTo.trim() : ""; if (to === "") return refusal("ask", 'ask needs a peer: pass `to`, e.g. "billing-core".'); // The same rule `assign` applies to a card: a question to yourself is mail you already // read, and the round is the better answer to it. if (namesSelf(me, to)) return refusal("ask", `${to} is this session — the round already says what you hold.`); const asked = typeof rawQuestion === "string" && rawQuestion.trim() !== "" ? rawQuestion.trim() : DEFAULT_QUESTION; let delivered: string[]; let failed: DeliveryFailure[]; try { const sent = await dispatch.send({ kind: "ask", to, text: askText(asked, me.label) }); delivered = sent.delivered; failed = sent.failed; } catch (error) { return refusal("ask", `the ask did not reach ${to}: ${messageOf(error)}`); } // The answer arrives as mail, to the label the question just named, so the call that asks // is the call that starts listening for it. await join(); const peers = await dispatch.peers().catch(() => undefined); const silent = notListening(peers, delivered); return textResult(formatAsk(delivered, silent, to, failed), { action: "ask", delivered, ...(failed.length === 0 ? {} : { failed }), ...(silent.length === 0 ? {} : { notListening: silent }), }); } /** * `claim`: the lead takes a card itself, through the store operation `board`'s claim makes. * * The lane rules, the WIP limit, the waiting refusal, and the offer record cleared are the * store's, so the two doors cannot answer them differently. The join is the claim's one * side effect outside the card: taking work is what makes this session a teammate, and it * is the same join `board` makes, asked again when the bus no longer holds this session. */ async function claimCard( store: BoardStore, me: Actor, id: string, now: number, join: () => Promise, ): Promise> | ToolFailure> { const before = await store.read(id); if (!before) return refusal("claim", `${cardRef(id)} does not exist.`); const result = await store.mutate(id, { kind: "claim" }, me, now); if (!result.ok) return refusal("claim", result.error, result.card ?? before); await join(); return textResult(formatClaimed(result.card), { action: "claim", card: result.card }); } /** `reclaim`: the owner clears and the card returns to the pool, notes and all. */ async function reclaimCard( store: BoardStore, me: Actor, id: string, now: number, ): Promise> | ToolFailure> { const before = await store.read(id); if (!before) return refusal("reclaim", `${cardRef(id)} does not exist.`); const previous = before.owner?.label; if (previous === undefined) return refusal("reclaim", `${cardRef(id)} has no owner to reclaim.`, before); const result = await store.mutate(id, { kind: "reclaim" }, me, now); if (!result.ok) return refusal("reclaim", result.error, result.card ?? before); return textResult(formatReclaimed(result.card, previous), { action: "reclaim", card: result.card }); } /** The collapsed line for a successful call, from its details. */ function summaryOf(details: TeamDetails): string { if (details.action === "ask") return `asked ${details.delivered?.join(", ") ?? "nobody"}`; const card = details.card; if (!card) return details.action === "reclaim" ? "reclaimed" : "handed off"; if (details.action === "claim") return `${cardRef(card.id)} claimed`; if (details.action === "offer") { const count = details.delivered?.length ?? 0; return `${cardRef(card.id)} offered to ${plural(count, "session")}`; } const who = details.assigned?.label ?? card.owner?.label; const target = details.action === "assign" && who !== undefined ? ` → ${who}` : ""; return `${cardRef(card.id)} ${card.lane}${target}`; }