diff --git a/src/agent_comms/commander/order.py b/src/agent_comms/commander/order.py index 8f545458b9..d66444aa01 100644 --- a/src/agent_comms/commander/order.py +++ b/src/agent_comms/commander/order.py @@ -543,23 +543,6 @@ def last_activity(self, unit): thread = self.comms.registry.require(unit) return thread.runtime.history_source(self.comms.root, thread.incarnation).last_tool_call() - def board_text(self) -> str: - """Render the live Todo view without saving a competing board file.""" - lines = ['# Fleet board', '', - "Live view of Core's Todo store; no board file is saved.", '', - '| Problem | Owner file | Owner | Order | Due | Check | Progress | Order state ' - '| Strikes | Owner last acted |', - '|---|---|---|---|---|---|---|---|---|---|'] - listed, read = self.todos(), self.progress() - activity = {unit: self.last_activity(unit) for unit in {t.holder for t in listed if t.holder}} - for t in sorted(listed, key=lambda t: ProblemNumber.parse(t.id).number): - lines.append(f'| {t.id}: {t.text.replace("|", "|")} | {t.order.board_cells(t)} | ' - f'{read[t.id].line if t.id in read else "-"} | {t.order.line} | ' - f'{t.strike_count} | {activity[t.holder].clock() if t.holder else "-"} |') - history = self.comms.goals.todos.history_for_commander(self.incarnation) - lines += ['', '## Withdrawn orders', '', - *(f'- {line}' for t in history for line in t.order.withdrawals(t))] - return '\n'.join(lines) + '\n' class OrderedProblems(ABC): @@ -1415,11 +1398,6 @@ def run(self, ctx: Commander) -> None: print(thread.name) -class BoardVerb(OrderVerb): - help = "show Core's current Todo store without saving a board file" - - def run(self, ctx: Commander) -> None: - print(ctx.board_text(), end='') def main(a, ctx: Commander): diff --git a/src/agent_comms/commander/rules/tests-and-commands.md b/src/agent_comms/commander/rules/tests-and-commands.md index 6258c64fbe..72dde39bea 100644 --- a/src/agent_comms/commander/rules/tests-and-commands.md +++ b/src/agent_comms/commander/rules/tests-and-commands.md @@ -12,5 +12,5 @@ type: feedback - Refactor tests with the code: per-member copies become one family test, hand-built setup one fixture owner. Tristan, 2026-10-18: "no stupid test theater, refactor the slop out of tests as you go, they're fucking sloppy". - A red test or failure you meet is fixed at its owner in the change at hand, or handed the same tick to the agent whose open work touches that code, named with its PR. "Not my change" without a named owner is a hidden failure, and every later run pays for it by asking whether a red is real. Tristan, 2026-10-22: "imagine if it doesn't get fixed and in the future you always end up checking oh is this a real failure?"; "not my change is valid if another agent is touching it, but they must be named". - An order check counts over the whole source tree (`grep -r ... src`), never one file. -- Run verb gate loops under `bash -c`: zsh passes a quoted "order board" as one argument. +- Run verb gate loops under `bash -c`: zsh passes a quoted "order idle" as one argument. - A wait or kill by process pattern brackets its first letter (`grep -q "[v]erify"`) or waits on a file or PID, or it matches itself. diff --git a/src/agent_comms/commander/vibe_check.py b/src/agent_comms/commander/vibe_check.py index 9c45c4ff0d..9096489391 100644 --- a/src/agent_comms/commander/vibe_check.py +++ b/src/agent_comms/commander/vibe_check.py @@ -189,16 +189,25 @@ class OverloadDegradation(Degradation, BlocksRelease, ToolResult): @classmethod def of(cls, comms: Comms, commander: str) -> Self: - return cls( - len(StandingRule.pending_todos(comms, commander)), + return cls.from_orders( + StandingRule.todos(comms, commander), len(ReviewTimerUp.violations(comms, commander)), OrderEditCountRead.count(comms, commander), ) + @classmethod + def from_orders(cls, orders: Sequence[Todo], timers_up: int, unjudged: int) -> Self: + """The same load projection for a captured HUD read and an independent rule read.""" + return cls(sum(todo.pending for todo in orders), timers_up, unjudged) + @projected(view="wire") def total(self) -> float: return self.owed + 2 * self.timers_up + self.unjudged / 10 + @projected(view="wire") + def overloaded(self) -> bool: + return self.total >= self.ceiling + def __str__(self) -> str: return ( f"load {self.total:.0f} ({self.owed} open orders, " @@ -208,7 +217,7 @@ def __str__(self) -> str: @classmethod def found(cls, comms: Comms, commander: str) -> tuple[Sign, ...]: load = cls.of(comms, commander) - if load.total < cls.ceiling: + if not load.overloaded: return () return (Sign(cls, (int(time.time() // cls.round_seconds),), f"{load}, over {cls.ceiling:.0f}"),) diff --git a/src/agent_comms/commander_hud.py b/src/agent_comms/commander_hud.py new file mode 100644 index 0000000000..467cd6f074 --- /dev/null +++ b/src/agent_comms/commander_hud.py @@ -0,0 +1,143 @@ +"""Commander display derived from the native record owners in one read.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from agent_comms.commander.config import CommanderConfig +from agent_comms.commander.vibe_check import OverloadDegradation +from agent_comms.field_codec import projected +from agent_comms.plan_phases import PlanPhase +from agent_comms.recorded_time import RecordedEventTime +from agent_comms.thread_identity import ThreadIncarnation +from agent_comms.todos import OrderProgress, ProgressRead, PullRequest, Todo, UnmergedListedPullRequest +from agent_comms.tool_result import ToolResult +from agent_comms.turn_digests import OrderEditCountRead + +if TYPE_CHECKING: + from agent_comms.comms import Comms + + +@dataclass(frozen=True) +class CommanderOrder: + """Small order card; identifiers/text come from Todo, review rounds from TodoStore.""" + + problem: str + text: str + order: str + state: str + progress: OrderProgress + review_timers_up: tuple[RecordedEventTime, ...] + strikes: int + unjudged_edits: int + + +@dataclass(frozen=True) +class CommanderUnit: + """One holder's orders, live strikes and GitHub's open ready/draft requests.""" + + unit: ThreadIncarnation + orders: tuple[CommanderOrder, ...] + strikes: int + ready_prs: tuple[PullRequest, ...] + draft_prs: tuple[PullRequest, ...] + + def line(self) -> str: + orders = "; ".join( + f"{order.problem}: {order.state}; {order.progress.line}" for order in self.orders + ) + return ( + f"{self.unit.name}: orders={orders} " + f"review timers={sum(len(order.review_timers_up) for order in self.orders)} " + f"strikes={self.strikes} edits={sum(order.unjudged_edits for order in self.orders)} " + f"ready={','.join(str(pr.number) for pr in self.ready_prs)} " + f"draft={','.join(str(pr.number) for pr in self.draft_prs)}" + ) + + +@dataclass(frozen=True) +class CommanderPhase: + """PlanPhase supplies every answer; no HUD status taxonomy is maintained beside it.""" + + phase: type[PlanPhase] + done: bool + ready: bool + holders: tuple[ThreadIncarnation, ...] + + def line(self) -> str: + return ( + f"{self.phase.plan.declared_name}/{self.phase.declared_name} " + f"done={self.done} ready={self.ready} " + f"holders={','.join(unit.name for unit in self.holders)}" + ) + + +@dataclass(frozen=True) +class CommanderHud(ToolResult): + """One disposable view of native records, shared by comms_hud and the commander board. + + TodoStore owns orders, review timers and strikes; OrderEditCountRead owns edit counts; + PullRequests owns ready/draft facts; PlanPhase owns phase state; OverloadDegradation + owns the load formula and decision. Nothing here writes or retains a board. + """ + + commander: ThreadIncarnation + units: tuple[CommanderUnit, ...] + phases: tuple[CommanderPhase, ...] + load: OverloadDegradation + + @classmethod + def read(cls, comms: Comms, commander: str) -> CommanderHud: + snapshot, now = comms.registry.snapshot(), time.time() + owner = snapshot.require(commander).incarnation + store = comms.goals.todos + history = store.history_for_commander(owner) + held = tuple(todo for todo in history if todo.holder) + pulls = ProgressRead(held) + progress = {todo.id: OrderProgress.read(todo, comms.registry, pulls) for todo in held} + timers = store.timers_up_in(held, now, progress) + edits = { + read.todo.id: read.read() + for read in OrderEditCountRead.for_orders(comms.root, snapshot, held) + } + grouped: dict[ThreadIncarnation, list[Todo]] = {} + for todo in held: + grouped.setdefault(todo.held().owner_identity, []).append(todo) + units = [] + for unit, orders in grouped.items(): + requests = { + (todo.repo, request.number): request + for todo in orders + for request in pulls.listed(todo.repo, UnmergedListedPullRequest, todo) + } + units.append(CommanderUnit( + unit, + tuple(CommanderOrder( + todo.id, todo.text, todo.order.checked().id, todo.order.line, progress[todo.id], + tuple(at for due, at in timers if due.id == todo.id), + todo.strike_count, edits[todo.id], + ) for todo in orders), + sum(len(todo.live_strikes(unit.name)) for todo in history), + tuple(request for request in requests.values() if not request.draft), + tuple(request for request in requests.values() if request.draft), + )) + reading = CommanderConfig.read_root(comms.root).plan_reading(history) + phases = tuple(CommanderPhase( + phase, phase.done(reading), phase.ready(reading), + tuple(dict.fromkeys(todo.held().owner_identity for todo in phase.holding(reading))), + ) for phase in PlanPhase.members_with(PlanPhase)) + return cls(owner, tuple(units), phases, + OverloadDegradation.from_orders(held, len(timers), sum(edits.values()))) + + @projected(view="wire") + def frame(self) -> tuple[str, ...]: + """One line per declared phase or holder, with their original order and PR identities.""" + return ( + str(self.load), + *(row.line() for row in self.phases), + *(row.line() for row in self.units), + ) + + diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index 9ba0a225c7..a0d07b626d 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -72,7 +72,7 @@ The declaration supplies the ID and first-call times when omitted. A mechanical ## The board -Core's Todo store is the only record of fixes and their history; `order board` renders it. Orders, timers, owners, delivery and installed evidence change only through Core's Todo operations. A defect with no owner gets one the same tick. +Core's Todo store is the only record of fixes and their history; `comms_hud` reads it beside plan phases, PRs and overload. Orders, timers, owners, delivery and installed evidence change only through Core's Todo operations. A defect with no owner gets one the same tick. Priority: the owner's latest message; a broken install, lost or replayed input, wrong messages, or a unit blocked on you; then the owner's plans, which are the default work. Slop found outside a plan goes into the plan's surface or a plan phase, never ahead of it. diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index f1008ce2f8..fd8696ff4b 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -511,6 +511,7 @@ class PullRequest(DeclaredFamily, affix="PullRequest"): orders: tuple[str, ...] = () base: str = field(default=WorkBranches.main, kw_only=True) problems: tuple[str, ...] = field(default=(), kw_only=True) + draft: bool = field(default=False, kw_only=True) def belongs_to(self, todo: Todo) -> bool: return (todo.id in self.problems @@ -563,10 +564,11 @@ class ListedPullRequest(DeclaredFamily, affix="ListedPullRequest"): head: str updated: str base: str + draft: bool = field(default=False, kw_only=True) def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> PullRequest: return UnmergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), - orders, base=self.base, problems=problems) + orders, base=self.base, problems=problems, draft=self.draft) class UnmergedListedPullRequest(ListedPullRequest): @@ -586,7 +588,7 @@ def parsed(self, problems: tuple[str, ...], orders: tuple[str, ...]) -> MergedPu return MergedPullRequest(self.number, self.branch, self.head, _github_time(self.updated), orders, base=self.base, merge=self.merge.oid, merged_at=_github_time(self.merged), - problems=problems) + problems=problems, draft=self.draft) class PullRequests: @@ -625,7 +627,7 @@ def read(self, repo: RepositoryName, kind: type[ListedPullRequest], return () selections = [ f'p{number}: pullRequest(number: {number}) {{ number branch: headRefName ' - 'head: headRefOid base: baseRefName closed merge: mergeCommit { oid } ' + 'head: headRefOid base: baseRefName draft: isDraft closed merge: mergeCommit { oid } ' 'merged: mergedAt updated: updatedAt }' for number in linked] query = (f'query {{ repository(owner: {json.dumps(repo.owner)}, ' f'name: {json.dumps(repo.name)}) {{ {" ".join(selections)} }} }}') @@ -640,16 +642,18 @@ def read(self, repo: RepositoryName, kind: type[ListedPullRequest], raise WorkBranchError( listed.stderr.strip() or f"GitHub order lookup in {repo.to_wire()} failed.") rows = FieldCodec.decode(dict[str, ListedPullRequest], FieldCodec.parse(listed.stdout)) - requests = tuple(row.parsed( + requests = tuple((row, row.parsed( tuple(candidate.id for candidate in linked[row.number]), tuple(dict.fromkeys(order.id for candidate in linked[row.number] for order in candidate.order_history - if PullRequestLink(repo, row.number) in order.pull_requests))) - for row in rows.values() if isinstance(row, kind)) - for candidate in problems.values(): - self._read[repo, kind, candidate.id] = tuple(sorted( - (request for request in requests if request.belongs_to(candidate)), - key=lambda request: request.updated_at, reverse=True)) + if PullRequestLink(repo, row.number) in order.pull_requests)))) + for row in rows.values()) + for selection in dict.fromkeys((kind, *ListedPullRequest.members_with(ListedPullRequest))): + for candidate in problems.values(): + self._read[repo, selection, candidate.id] = tuple(sorted( + (request for row, request in requests + if isinstance(row, selection) and request.belongs_to(candidate)), + key=lambda request: request.updated_at, reverse=True)) return self._read[repo, kind, todo.id] @@ -1562,9 +1566,6 @@ def reissued(self, at: float) -> PendingProblemOrder: """This order sent again at ``at`` to a new owner, its first-call window kept.""" raise ValueError("Only an undelivered order can change owner.") - def board_cells(self, todo: Todo) -> str: - return f"{self.finding.place()} | unassigned | - | - | -" - def checked(self) -> CheckedProblemOrder: raise ValueError("This problem has no checked order.") @@ -1843,13 +1844,6 @@ def declarations(self) -> tuple[CheckedProblemOrder, ...]: ) serves: Annotated[type[OrderPhase], WireOmitDefault()] = UnplannedPhase - def board_cells(self, todo: Todo) -> str: - last = todo.released[-1:] - owner = todo.holder or (f"unassigned: released from {last[0].previous.owner}: " - f"{last[0].reason.text}") - check = json.dumps(FieldCodec.encode(self.check)).replace("|", "|") - return f"{self.owner.to_wire()} | {owner} | {self.id} | {todo.review_at.clock()} | {check}" - @property def phase(self) -> type[OrderPhase]: return self.serves @@ -3845,9 +3839,15 @@ def timers_up_for_commander(self, commander: ThreadIncarnation, registry: Regist """Held work whose review timer is up, with its round; merged work waits on install.""" held = self.held_for_commander(commander) pulls = ProgressRead(held) + progress = {todo.id: OrderProgress.read(todo, registry, pulls) for todo in held} + return self.timers_up_in(held, now, progress) + + @staticmethod + def timers_up_in(held: Sequence[Todo], now: float, + progress: Mapping[str, OrderProgress]) -> tuple[tuple[Todo, RecordedEventTime], ...]: + """Review rounds from this read's orders and PR facts, excluding installation waits.""" return tuple((todo, up) for todo in held for up in todo.timer_up(now) - if not todo.order.waiting_on_install( - todo, {todo.id: OrderProgress.read(todo, registry, pulls)})) + if not todo.order.waiting_on_install(todo, progress)) def held_for_commander(self, commander: ThreadIncarnation) -> tuple[Todo, ...]: return self._select("creator=? AND creator_created=? AND assignment IS NOT NULL", diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index dc48c865a3..861e6b19a9 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -57,6 +57,7 @@ ) from .cli_result import ToolCatalogResult from .commander.vibe_check import OverloadDegradation +from .commander_hud import CommanderHud from .coordination_tables.refusals import SendRefusal from .declared_family import DeclaredFamily from .display_order import ChannelSort, ThreadSort @@ -982,6 +983,18 @@ def apply(self, comms: Comms, caller: ToolCaller) -> OverloadDegradation: return OverloadDegradation.of(comms, caller.subject(self.thread)) +@dataclass(frozen=True, kw_only=True) +class CommsHudTool(ThreadReadTool): + label = "Commander HUD" + description = ( + "comms_hud reads orders by holder, review timers, strikes, unjudged edit counts, " + "ready/draft PRs, declared plan phases and the overload decision from their owners." + ) + + def apply(self, comms: Comms, caller: ToolCaller) -> CommanderHud: + return CommanderHud.read(comms, caller.subject(self.thread)) + + @dataclass(frozen=True, kw_only=True) class CommsFireTool(ThreadToolRequest): label = "Fire unit" diff --git a/tests/test_commander_forks.py b/tests/test_commander_forks.py index 4bd444d1a1..e90b4c2f8a 100644 --- a/tests/test_commander_forks.py +++ b/tests/test_commander_forks.py @@ -35,7 +35,7 @@ from agent_comms.problem_number import ProblemNumber from agent_comms.thread_execution import ExternalThreadExecution from agent_comms.thread_identity import RecordedThreadIncarnation -from agent_comms.tools import CommsCommanderLoadTool, CommsTodoListTool +from agent_comms.tools import CommsCommanderLoadTool, CommsHudTool, CommsTodoListTool from agent_comms.thread_model import DeclaredModel from agent_comms.todos import ( DeclarationDerivation, PendingProblemOrder, ProblemAllocation, ReleaseTodo, @@ -145,7 +145,10 @@ def test_registered_fork_sends_parent_problem_with_fork_actor(fork_bus, tmp_path store.require_unit_in(db, saved.held().owner_identity, reissuer) print(f'Core send actor: {saved.held().parent}; problem commander: {saved.creator}') ctx = CommanderContext.read_root(comms.root).model_copy(update={'actor': CommanderForkCaller(fork.name)}) - assert ctx.board_text() == Commander.read_root(comms.root).board_text() + hud = ctx.call(CommsHudTool(thread=ctx.commander)) + assert hud.commander == parent.incarnation + assert [(unit.unit, tuple(order.problem for order in unit.orders)) + for unit in hud.units] == [(saved.held().owner_identity, ('P40',))] assert set(ctx.model_copy(deep=True).progress()) == {'P40'} assert ctx.held_orders() == (saved,) assert ctx.call(CommsTodoListTool(thread=ctx.commander)).todos == (saved,) diff --git a/tests/test_order_claims.py b/tests/test_order_claims.py index 4e41fc9200..91860fbda1 100644 --- a/tests/test_order_claims.py +++ b/tests/test_order_claims.py @@ -28,7 +28,7 @@ ) from agent_comms.tool_caller import ThreadCaller from agent_comms.tools import ( - CommsHistoryTool, CommsOrderDeliverTool, CommsVerdictTool, TodoChanged, + CommsHistoryTool, CommsHudTool, CommsOrderDeliverTool, CommsVerdictTool, TodoChanged, ) from test_held_problem_orders import ( # noqa: F401 - shared copied-bus fixture family held_commander, @@ -68,7 +68,7 @@ def test_claim_verdict_family_on_retained_copy(held_commander): if message.message_id == claimed.order.claim.message.message_id) assert sent.sender == "worker" and sent.target == ctx.commander assert sent.answers and sent.answers == claimed.order.claim.message.answers - assert "Claimed by the unit" in ctx.board_text() + assert "Claimed by the unit" in "\n".join(ctx.call(CommsHudTool()).frame) with pytest.raises(TodoError, match="Only a pending order"): ThreadCaller("worker").invoke(request, comms) assert comms.goals.todos.get("P1") == claimed diff --git a/tests/test_todos.py b/tests/test_todos.py index 4374a3d6d6..9e417ad11f 100644 --- a/tests/test_todos.py +++ b/tests/test_todos.py @@ -42,6 +42,7 @@ from agent_comms.store_files import store_lock_path from agent_comms.thread_identity import CoreWatcherIncarnation from agent_comms.threads import Thread +from agent_comms.tools import CommsHudTool from agent_comms.todos import ( Assignment, ChangeTodoState, @@ -529,7 +530,7 @@ def test_installed_merge_closes_only_the_run_that_has_been_proven(pr_commander): assert application.order.sha == mechanical.order.sha assert not application.order.accepted assert application.order.line == application.order.checked().run.merged_line - board = checked.board_text() + board = "\n".join(checked.call(CommsHudTool()).frame) assert application.order.line in board assert worker.name in board store.record_merges(store.merged_orders(checked.incarnation, checked.comms.registry))