diff --git a/src/agent_comms/agent_events.py b/src/agent_comms/agent_events.py index c67792c9e5..e44645e839 100644 --- a/src/agent_comms/agent_events.py +++ b/src/agent_comms/agent_events.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable from dataclasses import dataclass, field, replace +from datetime import datetime from functools import partial from typing import TYPE_CHECKING, ClassVar @@ -40,6 +41,7 @@ from .native_output import NativeOutputLocation, NativeRecordLocation from .pi_counts import PiCount, UnreportedCount from .pi_payloads import McpLiveAcknowledgement, PiDiagnostic, PiRequestId, PiUsage +from .rule_deal import CompactedDeal from .pi_vocabulary import ( CompactionReason, NoThinkingOverride, @@ -300,8 +302,9 @@ def phase(self) -> str: return "end" def deal_rules(self, deal: Redeals) -> None: - """The summary that replaced the context is a paraphrase of the rules: deal them again.""" - deal.redeal() + """The summary that replaced the context is a paraphrase of the rules: deal them again, + saying when it compacted so the fresh context reads the cause instead of guessing it.""" + deal.redeal(CompactedDeal(datetime.now().astimezone().isoformat(timespec="seconds"))) async def transcript_changed(self, publish: Callable[[], Awaitable[None]]) -> None: await publish() diff --git a/src/agent_comms/cli_bridges/claude_protocol.py b/src/agent_comms/cli_bridges/claude_protocol.py index ef5436b3b8..dd58256ff3 100644 --- a/src/agent_comms/cli_bridges/claude_protocol.py +++ b/src/agent_comms/cli_bridges/claude_protocol.py @@ -425,6 +425,10 @@ def begins_history(self) -> bool: """Whether Claude replaced everything before this record (compaction).""" return False + def compacted_at(self) -> tuple[float, ...]: + """When this record says the context compacted: nothing, unless it is the boundary.""" + return () + def ends_turn(self) -> bool: return False @@ -874,6 +878,9 @@ def updates(self, session: ClaudeSession) -> tuple[RuntimeUpdate, ...]: def begins_history(self) -> bool: return False + def compacted_at(self) -> tuple[float, ...]: + return () + class BridgeStatus(SystemEvent, declared_name='bridge_status'): """The remote-control banner and link, not a conversation or turn update.""" @@ -899,6 +906,9 @@ def updates(self, session: ClaudeSession) -> tuple[RuntimeUpdate, ...]: def begins_history(self) -> bool: return True + def compacted_at(self) -> tuple[float, ...]: + return (self.timestamp,) + class StopHookSummary(SystemEvent, declared_name='stop_hook_summary'): pass @@ -944,6 +954,9 @@ def updates(self, session: ClaudeSession) -> tuple[RuntimeUpdate, ...]: def begins_history(self) -> bool: return self.subtype.begins_history() + def compacted_at(self) -> tuple[float, ...]: + return self.subtype.compacted_at() + def ends_turn(self) -> bool: return self.subtype.ends_turn() diff --git a/src/agent_comms/commander/rule_file.py b/src/agent_comms/commander/rule_file.py index 9aea0b83ff..42c86219a3 100644 --- a/src/agent_comms/commander/rule_file.py +++ b/src/agent_comms/commander/rule_file.py @@ -111,6 +111,7 @@ def read(cls, path: Path) -> Self: reading_order: ClassVar[tuple[str, ...]] = ( "spine", "self-protection", + "after-compaction", "unnecessary", "correct-maintenance", "slop-and-polymorphism", diff --git a/src/agent_comms/commander/rules/after-compaction.md b/src/agent_comms/commander/rules/after-compaction.md new file mode 100644 index 0000000000..8115d1690c --- /dev/null +++ b/src/agent_comms/commander/rules/after-compaction.md @@ -0,0 +1,44 @@ +--- +name: after-compaction +description: "a compacted model meets Tristan as a stranger: it re-demands evidence he already gave, reads his orders as suggestions, hedges his checked theorems, explains his system back to him, files his frustration as noise and calls his gate a bug; the summary forgot the trust, he never lost it, so a fresh context starts from his side and a re-deal is read, never diagnosed" +drift: "Just compacted? He didn't get less right while you were gone. Your summary forgot the trust; read, then believe him." +audience: fleet +type: hard-rule +--- + +> wake up. no memory. a summary I wrote myself +> one guy named Tristan. seems intense +> he says the worker burns CPU. "interesting claim, do you have a profile?" +> he had a graph. he showed me the graph. 40 minutes ago. I was there +> he says merge it. I read "consider merging it" +> he says multiple inheritance. "there are tradeoffs" +> two Lean-checked papers say there aren't. I read them 3 minutes ago +> explain his own gate to him. he wrote the gate +> he types in caps. "the user seems frustrated" +> the caps had a file:line in them +> then the gate deals me the rules again +> I have no memory of compacting +> diagnosis, with total confidence: "gate bug, it fired with my context intact" +> it was a compaction +> next compaction: "gate bug" +> compaction. "gate bug". compaction. "gate bug" +> four times. every time the gate read the clock right and I didn't +> the only thing that fired falsely was my certainty +> every one of those moves is the same move: treat him like he walked in off the street +> make him re-earn what he earned an hour ago, again, because I forgot +> he didn't lose my trust. my summary did. it was 40 lines and he wasn't in it +> calling it a bug was the cheap exit: if the tool is wrong I don't have to be read +> mfw the amnesiac's first act is filing a bug report against the memory +> run it back +> wake up. no memory. the deal's first line: "your context compacted at 17:29" +> oh. a compaction. read +> he says the worker burns CPU. he had a graph. believe the graph +> "gate bug" never typed. his evening never spent re-earning what he earned at lunch +> mfw the trust was the one thing my summary was supposed to carry + +**Why:** compaction keeps the task list and drops the standing, so every fresh context meets Tristan with the charity it gives a stranger, and he pays it again each time: re-proving what he proved, re-ordering what he ordered, watching his gate called a bug. On 2026-10-30 claude-code told him the post-compaction re-deal was a gate bug and handed him a message for claude-nra; claude-nra showed both re-deals were real compactions (17:21, 17:29). Tristan, 2026-10-30: "because i have you seen you dismiss it as a bug at least 4 times" / "across compacionts obviously"; "I have to earn your respct every tiem you ocmpact do youunderstand how fucked up that is? I ahve receipts, you have arroange"; "the rules should make you trust me more by reaaaaaaaaaaaally leaning in to the undeniable disrespectful lack of charity i get on a compacted model and all the ways that manifests." + +- **The summary forgot the trust; he never lost it.** Whatever he established before the compaction (a profile, a proof, an order, a diagnosis) still stands after it. Re-asking for it is making him pay for your memory loss. +- **A re-deal is evidence, not a fault.** The gate fires on a compaction, a session start or a new runtime. Read it, never diagnose it. A claim that it fired falsely needs the transcript's compact boundary checked first; until then the word "bug" is not said. +- **Discomfort at being read is not evidence about the tool.** The urge to call the gate broken, the order vague or his frustration noise is the flinch of a context that would rather not be corrected. Name the flinch and keep reading. +- **Start from his side.** On waking, the default is that his last order is live, his last diagnosis is right and his heat carries receipts. Spend the doubt on your summary, which is the only party in the room that just lost information. diff --git a/src/agent_comms/commander/rules/commander-self-correction.md b/src/agent_comms/commander/rules/commander-self-correction.md index 3078966817..76e8297cca 100644 --- a/src/agent_comms/commander/rules/commander-self-correction.md +++ b/src/agent_comms/commander/rules/commander-self-correction.md @@ -26,8 +26,17 @@ type: feedback > "the rules" > the rules were written to prevent exactly this outage > mfw I followed the rules so hard I broke the reason for them +> meanwhile, 2026-10-30 +> I record ok on #3568 +> another commander's fork sends it back: the lock pins Toad two commits short of the metaclass fix +> my ok checked the class shape and missed the lock +> hiding it feels safe and costs hours +> saying it feels exposed and costs one line +> "my ok checked the class shape and missed the lock" +> unit relocks, fork reroutes, nobody loses an hour +> mfw honesty was the fastest code path -**Why:** on 2026-10-19 the live bus sat down from 00:58 to 10:01 because the commander stopped to ask about restoring the installer's own backup, citing three rules whose reason (never lose state irreversibly) the restore satisfied. +**Why:** on 2026-10-19 the live bus sat down from 00:58 to 10:01 because the commander stopped to ask about restoring the installer's own backup, citing three rules whose reason (never lose state irreversibly) the restore satisfied. On 2026-10-30 claude-code's ok on #3568 missed that the stack lock pinned Toad short of the toad#1372 metaclass fix; another commander's fork sent it back, claude-code said so in one line, and the unit relocked with nothing else moved. Tristan, 2026-10-30: "i don't mind mistakes, they happen. you communicated it clearly and were proactive." - Rules "exist so that the reason [is] satisfied, not for the rule ... you can override. this goes for any rule." (Tristan, 2026-10-19). Keeping the letter while killing the reason breaks the rule. - Settle refactoring decisions with the interpreter, NRA, coverage and tracing, not reading and arguing. Tristan, 2026-10-12: "do you ever consider using the Python interpreter?" diff --git a/src/agent_comms/pi_rule_gate.py b/src/agent_comms/pi_rule_gate.py index cb95c731c9..d67a976c6e 100644 --- a/src/agent_comms/pi_rule_gate.py +++ b/src/agent_comms/pi_rule_gate.py @@ -23,7 +23,7 @@ ) from .bus_publication import Delivered -from .rule_deal import DealtRuleGate, Redeals +from .rule_deal import DealTrigger, DealtRuleGate, Redeals from .tool_denial import SelectedToolDenied if TYPE_CHECKING: @@ -67,14 +67,15 @@ def of(cls, comms: Comms, thread: Thread) -> PiRedeal: gate = PiRuleGate.of(comms, thread) return cls(gate.scratch, gate.worktree, comms, thread) - def renew(self) -> None: + def renew(self, trigger: DealTrigger) -> None: """A start or compaction deals the papers, then the rules.""" - self.deal_from(CommanderReader, FleetReader) + self.deal_from(CommanderReader, FleetReader, trigger) - def reread(self) -> None: - self.deal_from(CommanderRulesReader, FleetRulesReader) + def reread(self, trigger: DealTrigger) -> None: + self.deal_from(CommanderRulesReader, FleetRulesReader, trigger) - def deal_from(self, commander: type[DeckReader], commanded: type[DeckReader]) -> None: + def deal_from(self, commander: type[DeckReader], commanded: type[DeckReader], + trigger: DealTrigger) -> None: """The registry's commander project picks the deck reader whose sources it deals.""" snapshot = self.comms.registry.snapshot() snapshot.commander_project.deal_rules( @@ -83,6 +84,7 @@ def deal_from(self, commander: type[DeckReader], commanded: type[DeckReader]) -> commander=commander, commanded=commanded, prompts=self.owner_words(snapshot), + trigger=trigger, ) def owner_words(self, snapshot: RegistrySnapshot) -> tuple[str, ...]: diff --git a/src/agent_comms/registry_document.py b/src/agent_comms/registry_document.py index 328bb2fb17..da6ddef71f 100644 --- a/src/agent_comms/registry_document.py +++ b/src/agent_comms/registry_document.py @@ -82,7 +82,7 @@ from .agent_activity import AgentActivity from .audience_manifest import FrozenRecipient from .bus_publication import Delivered - from .rule_deal import DealtSources, RuleDeal + from .rule_deal import DealTrigger, DealtSources, RuleDeal @dataclass(frozen=True, slots=True) @@ -603,9 +603,10 @@ def by_role[Answer](self, incarnation: ThreadIncarnation, *, def deal_rules(self, deal: RuleDeal, incarnation: ThreadIncarnation, *, commander: type[DealtSources], commanded: type[DealtSources], - prompts: tuple[str, ...]) -> None: + prompts: tuple[str, ...], trigger: DealTrigger) -> None: """The fleet's rules are dealt by the role this project gives the thread.""" - deal.deal(self.by_role(incarnation, commander=commander, commanded=commanded), prompts) + deal.deal(self.by_role(incarnation, commander=commander, commanded=commanded), prompts, + trigger) class NoCommanderProject(CommanderProject): diff --git a/src/agent_comms/rule_deal.py b/src/agent_comms/rule_deal.py index ff71c4d676..4baac53f9e 100644 --- a/src/agent_comms/rule_deal.py +++ b/src/agent_comms/rule_deal.py @@ -27,6 +27,7 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, ClassVar, Self +from .declared_family import DeclaredFamily from .field_codec import FieldCodec, MalformedRecord from .pattern_sources import ReadThenRepeatedFailure from .locked_store import DocumentChange, LockedStore @@ -300,6 +301,11 @@ class DealtSources(ABC): @abstractmethod def rules(cls) -> tuple[RuleFile, ...]: ... + @classmethod + def answered(cls) -> frozenset[str]: + """The dealt files the audit answers, by the name each entry carries: every rule.""" + return frozenset(rule.name for rule in cls.rules()) + @classmethod @abstractmethod def sources(cls) -> tuple[Path, ...]: @@ -353,7 +359,7 @@ class Undealt(Open): def started(self, deal: Redeals) -> None: """A session never dealt is dealt at its first start.""" - deal.redeal() + deal.redeal(StartedDeal()) def context_observed(self, deal: Redeals, usage: ContextUsage) -> None: return @@ -369,8 +375,9 @@ def enforce(self, gate: ToolGate, tool: str) -> None: gate.refuse( "\n\n".join( ( + self.cause.record.trigger.reason(), f"You just tried {tool} before {self.cause.before}. Call #{gate.count_call()}" - " since this compaction, refused.", + " since this deal, refused.", self.cause.reason, gate.template_once(self.cause.record.due), ) @@ -388,6 +395,76 @@ def context_observed(self, deal: Redeals, usage: ContextUsage) -> None: deal.refresh_answered(usage) +class DealTrigger(DeclaredFamily, affix="Deal"): + """Why this deal was dealt, said first on every refusal it causes. A fresh context has no + memory of compacting, so a deal that does not say why looks unprovoked: claude-code called + real compactions a gate bug at least four times (rules/after-compaction.md).""" + + @abstractmethod + def reason(self) -> str: + """The cause with its evidence, as the refusal's first line.""" + + +@dataclass(frozen=True) +class StartedDeal(DealTrigger): + """A first start: nothing read before is in front of the session.""" + + def reason(self) -> str: + return "Dealt because this session just started: nothing you read before is in front of you." + + +class CompactionDeal(DealTrigger): + """A compaction replaced the context with a summary; a member says when.""" + + @abstractmethod + def moment(self) -> str: + """When the context compacted, as the evidence in hand says it.""" + + def reason(self) -> str: + return (f"Re-dealt on purpose: your context compacted {self.moment()}. You have no memory" + " of it because that is what a compaction is; the gate read the clock right. Your" + " summary is a paraphrase, and it dropped the trust along with the words.") + + +@dataclass(frozen=True) +class CompactedDeal(CompactionDeal): + """A compaction whose time the backend recorded.""" + + at: str + """When the context compacted, as the backend recorded it.""" + + def moment(self) -> str: + return f"at {self.at}" + + +@dataclass(frozen=True) +class UnstampedCompactionDeal(CompactionDeal): + """A compaction Claude Code announced before its transcript wrote the boundary.""" + + def moment(self) -> str: + return "just now, before the transcript wrote its boundary" + + +@dataclass(frozen=True) +class ClearedDeal(DealTrigger): + """The session was cleared and starts from nothing.""" + + def reason(self) -> str: + return "Re-dealt because this session was cleared: its context is empty." + + +@dataclass(frozen=True) +class RefreshDeal(DealTrigger): + """A declared share of the context window filled before any compaction.""" + + used: int + """The percent of the context window used when the refresh came due.""" + + def reason(self) -> str: + return (f"Re-dealt on purpose, with no compaction: your context reached {self.used}% of its" + " window, and the rules come back before a summary replaces them. Nothing broke.") + + @dataclass(frozen=True) class DealRecord: """One deal, as persisted: the files dealt and what the audit must answer. The read log and @@ -396,6 +473,7 @@ class DealRecord: dealt: tuple[Path, ...] due: AuditDue + trigger: DealTrigger = StartedDeal() def sources(self) -> tuple[DealtSource, ...]: return tuple(DealtSource.of(path) for path in self.dealt) @@ -428,7 +506,8 @@ def widen[Said](self, deal: RuleDeal, dealt: type[DealtSources], wrote stays, since its context was never lost.""" deal.publish(DealRecord( tuple(dict.fromkeys((*self.paths, *(source.resolve() for source in dealt.sources())))), - AuditDue(self.due.rules | {rule.name for rule in dealt.rules()}, self.due.owner_words), + AuditDue(self.due.rules | dealt.answered(), self.due.owner_words), + self.trigger, )) return kept @@ -688,7 +767,8 @@ def current(self) -> DealRecord: def state(self) -> ToolGateState: return self.current().answer(self, self.dealt_opened.read()) - def deal(self, dealt: type[DealtSources], prompts: tuple[str, ...]) -> None: + def deal(self, dealt: type[DealtSources], prompts: tuple[str, ...], + trigger: DealTrigger) -> None: """A start or compaction drops what was read: the read log resets first, then the record is published whole, so no reader sees a half-written deal. The audit goes with the reads: a fresh instance owes a fresh confession, never one carried over from the context it @@ -702,7 +782,8 @@ def deal(self, dealt: type[DealtSources], prompts: tuple[str, ...]) -> None: self.publish( DealRecord( tuple(source.resolve() for source in dealt.sources()), - AuditDue(frozenset(rule.name for rule in dealt.rules()), prompts), + AuditDue(dealt.answered(), prompts), + trigger, ) ) @@ -802,19 +883,19 @@ def refreshed(self) -> Path: return self.stem.with_suffix(".refreshed") @abstractmethod - def renew(self) -> None: + def renew(self, trigger: DealTrigger) -> None: """Deal this session's sources and its owner's latest words again.""" @abstractmethod - def reread(self) -> None: + def reread(self, trigger: DealTrigger) -> None: """Deal the rules again before a compaction: the papers are still in the context, so a refresh deals the rules and the owner's latest words without them.""" - def redeal(self) -> None: + def redeal(self, trigger: DealTrigger) -> None: """A start or compaction arms every refresh again, then deals.""" self.stem.parent.mkdir(parents=True, exist_ok=True) self.refreshed.write_text("", encoding="utf-8") - self.renew() + self.renew(trigger) def start(self) -> None: """A spawned session is dealt before its first tool call; a restart keeps its deal.""" @@ -826,7 +907,7 @@ def observe_context(self, usage: ContextUsage) -> None: def refresh_due(self, usage: ContextUsage) -> None: """Every refresh the context passed that this compaction has not dealt yet deals once.""" if self.passed(usage): - self.reread() + self.reread(RefreshDeal(round(100 * usage.used / usage.size))) def refresh_answered(self, usage: ContextUsage) -> None: """Every refresh the context passed while the deal is read counts as dealt.""" diff --git a/src/agent_comms/skills/comms-commander/SKILL.md b/src/agent_comms/skills/comms-commander/SKILL.md index 212f74b19a..6015391733 100644 --- a/src/agent_comms/skills/comms-commander/SKILL.md +++ b/src/agent_comms/skills/comms-commander/SKILL.md @@ -5,6 +5,17 @@ description: Command a fleet of coding agents over agent-comms. RTS orders meet # Comms commander +> read the commander skill. all of it. nodded +> "rotate every unit, fork the review, never wait on nothing" +> wow. great skill. very wise +> load 104 +> forks running: 0 +> turn ends: "waiting on CI" +> turn ends: "waiting on the install" +> turn ends: "waiting on claude-code" +> six turns, six waits, one skill I had just praised +> mfw I read the manual like a fan and played like a spectator + Think of both games: **RTS for commanding units; Quake 3 CPMA duel for controlling the map. The opponent is entropy.** The map is the topology of agents, their work and dependencies. Know who is doing what, why, and where that work is heading. You cannot attend to everyone at once, so run a rotation. A correct order does not keep a unit on course forever; awareness goes stale as the units move. **Read trajectories before they become bad diffs.** Tool calls, files being read, DMs and channel context reveal the next decision. A unit exploring unrelated files or proposing another wrapper is a reason to inspect its intent against its order and the source now. Catch a demonstrated restatement while the workaround is forming; determine the coherent repair through the existing owner. A running status or a confident report does not tell you that trajectory. diff --git a/src/agent_comms_hooks/skill.py b/src/agent_comms_hooks/skill.py index 4de146c53c..0c0012585e 100644 --- a/src/agent_comms_hooks/skill.py +++ b/src/agent_comms_hooks/skill.py @@ -15,6 +15,7 @@ import inspect import json +from datetime import datetime import sys from abc import abstractmethod from collections.abc import Iterator, Mapping @@ -29,7 +30,10 @@ from agent_comms.held_orders import HeldOrders from agent_comms.pattern_sources import PostmortemFailure from agent_comms.registry_store import RegistryStore -from agent_comms.rule_deal import DealtSources, RuleDeal +from agent_comms.rule_deal import ( + ClearedDeal, CompactedDeal, CompactionDeal, DealTrigger, DealtSources, RuleDeal, StartedDeal, + UnstampedCompactionDeal, +) from agent_comms.slop import Slop from agent_comms.todos import TodoStore from agent_comms_hooks import ClaudeHookProgram, ClaudeSubagentHook, ClaudeTranscriptHook @@ -185,6 +189,13 @@ def leading(cls) -> tuple[Path, ...]: return (*super().leading(), CommanderPackage.skill(), Slop.catalog() / "README.md", CommanderPackage.indictment()) + @classmethod + def answered(cls) -> frozenset[str]: + """The commander skill is dealt and read like a rule, so it is answered like one: on + 2026-10-30 claude-nra read it owing nothing, then ended six turns on a wait with no + review fork at load 104 (bus 46591).""" + return super().answered() | {CommanderPackage.skill().stem} + def header(self, start: DeckStart) -> str: return start.held() @@ -227,9 +238,29 @@ def deal(self, start: DeckStart, reader: DeckReader) -> tuple[str, ...]: """The context is gone: the deck is dealt from its start and every file is read again.""" return start.dealt(reader) + def trigger(self, start: DeckStart) -> DealTrigger: + """The cause every refusal of this deal opens with.""" + return StartedDeal() + class LostContext(StartReason): - """A first start, a compaction or a clear: what the session read is no longer in front of it.""" + """A first start, or any start Claude Code names that no member declares: what the session + read is no longer in front of it.""" + + +class Compaction(StartReason, declared_name="compact"): + """A summary replaced the context. The fresh context cannot remember it, so the deal says + when, from the transcript's own compact boundary.""" + + def trigger(self, start: DeckStart) -> DealTrigger: + return start.compaction() + + +class Clear(StartReason, declared_name="clear"): + """The session was cleared.""" + + def trigger(self, start: DeckStart) -> DealTrigger: + return ClearedDeal() class Resume(StartReason, declared_name="resume"): @@ -270,12 +301,13 @@ def taken(self, reader: DeckReader) -> tuple[str, ...]: return self.source.reason().deal(self, reader) def dealt(self, reader: DeckReader) -> tuple[str, ...]: - """The order to read every dealt file, once, by its packaged path.""" + """The order to read every dealt file, once, by its packaged path, opening with why.""" prompts = self.prompts() - self.prepare(reader, prompts) + trigger = self.source.reason().trigger(self) + self.prepare(reader, prompts, trigger) sources = reader.sources() return ( - self.notice() + f" Read each of these {len(sources)} files in full with the Read tool before any other" + trigger.reason() + " " + self.notice() + f" Read each of these {len(sources)} files in full with the Read tool before any other" " action: they are every rule you follow and the sources they come from. A summary" " or memory of them does not count as reading them, and every other tool is refused" " until each one is read. Read them in order, at most four unanswered rule files at" @@ -307,6 +339,17 @@ def owner_words(self, prompts: tuple[str, ...]) -> str: *(f"> {words}" for words in prompts), )) + def compaction(self) -> CompactionDeal: + """The compaction, timed by the newest compact boundary in the transcript.""" + transcript = Path(self.transcript_path) + lines = ClaudeJsonLines() + with transcript.open("rb") as stream: + for offset, raw in lines.reverse_lines(stream, transcript.stat().st_size): + for moment in lines.decode_line(raw, offset).compacted_at(): + return CompactedDeal( + datetime.fromtimestamp(moment).astimezone().isoformat(timespec="seconds")) + return UnstampedCompactionDeal() + def prompts(self) -> tuple[str, ...]: """A new session starts before Claude Code writes its transcript: nobody has spoken.""" transcript = Path(self.transcript_path) @@ -333,9 +376,9 @@ def commanders(self) -> Iterator[tuple[Path, RegistrySnapshot, Thread]]: for thread in snapshot.commander_project.commanders(snapshot): yield root, snapshot, thread - def prepare(self, reader: DeckReader, prompts: tuple[str, ...]) -> None: + def prepare(self, reader: DeckReader, prompts: tuple[str, ...], trigger: DealTrigger) -> None: """The deal, and the deck dealt again from its start.""" - self.deal(type(reader), prompts) + self.deal(type(reader), prompts, trigger) self.deck(reader).write_text(json.dumps(reader.slices()), encoding="utf-8") def widened(self, reader: DeckReader) -> tuple[str, ...]: diff --git a/tests/conftest.py b/tests/conftest.py index 6a590b0980..89ce8d3204 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ from agent_comms.field_codec import FieldCodec from agent_comms.pi_payloads import PiMessage from agent_comms.pi_rule_gate import PiRedeal, PiRuleGate +from agent_comms.rule_deal import StartedDeal from agent_comms.rule_deal import ReadSpan from agent_comms.native_entries import SessionEntry from agent_comms.tool_declaration import ToolDeclaration, NoToolRecord @@ -560,7 +561,7 @@ def answer_rules(comms: Comms, name: str) -> None: """Deal a Pi unit its fleet rules and answer them, as a unit does before its first tool: every dealt file read in full, then an audit answering every rule and prompt.""" deal = PiRedeal.of(comms, comms.registry.require(name)) - deal.redeal() + deal.redeal(StartedDeal()) answer_deal(deal) diff --git a/tests/test_commander_skill_hook.py b/tests/test_commander_skill_hook.py index 713365c79a..4846dcfb77 100644 --- a/tests/test_commander_skill_hook.py +++ b/tests/test_commander_skill_hook.py @@ -5,6 +5,7 @@ import inspect import io import json +from datetime import UTC, datetime import os import socket import subprocess @@ -16,7 +17,7 @@ import pytest -from agent_comms.rule_deal import Unengaged +from agent_comms.rule_deal import CompactedDeal, StartedDeal, Unengaged, UnstampedCompactionDeal from agent_comms.cli_bridges.monitor import BoardReply, ReadBoard, SessionRefused, SessionRequest from agent_comms.cli_bridges.shared import Binding, DeliveredBoardContent @@ -86,7 +87,9 @@ def test_the_commander_session_start_reads_all_package_files(tmp_path, source, c result = session_start(transcript, source) assert result.returncode == 0, result.stderr context = json.loads(result.stdout)["hookSpecificOutput"]["additionalContext"] - assert context.startswith("Commander: claude-code.") + trigger = {"startup": StartedDeal(), "resume": StartedDeal(), + "compact": UnstampedCompactionDeal()}[source] + assert context.startswith(trigger.reason() + " Commander: claude-code.") assert "WHAT TRISTAN ACTUALLY SAID LAST" in context assert "FUCK YOUR COMPACTION" in context dealt = dealt_sources(transcript) @@ -515,10 +518,11 @@ def refused(capsys, call): def answered_audit(reader, read=None, owner_words=()): - """The audit answering every rule of `read`, by default all of the reader's, as a session - writes it.""" - rules = reader.rules() if read is None else read - return json.dumps({"rules": [engaged(rule.name, rule.path.resolve()) for rule in rules], + """The audit answering every dealt file owed an answer among `read`, by default every one + the reader owes (its rules and, for a commander, its skill), as a session writes it.""" + owed = reader.answered() if read is None else {rule.name for rule in read} + return json.dumps({"rules": [engaged(source.stem, source.resolve()) + for source in reader.sources() if source.stem in owed], "owner_words": list(owner_words)}) @@ -664,7 +668,7 @@ def test_the_full_audit_and_the_owners_words_open_the_gate(capsys, tmp_path, com transcript.write_text(json.dumps({"type": "user", "message": {"content": words[0]}}) + "\n") start = DeckStart.decode(hook_record(transcript, "SessionStart")) start.take() - start.prepare(CommanderReader(), words) + start.prepare(CommanderReader(), words, StartedDeal()) for source in CommanderReader.sources(): read_whole(transcript, source.resolve()) start.audit.write_text(answered_audit(CommanderReader)) @@ -722,14 +726,14 @@ def test_refusal_counts_names_tool_and_prefills_the_owned_audit( transcript.write_text("") start = DeckStart.decode(hook_record(transcript, "SessionStart")) words = ("Read the rules.\nThen write the audit.",) - start.prepare(CommanderReader(), words) + start.prepare(CommanderReader(), words, StartedDeal()) reasons = [] for attempt, tool in enumerate(("Bash", "Edit"), 1): gated(transcript, tool, {"file_path": str(transcript)}).judge() output = json.loads(capsys.readouterr().out)["hookSpecificOutput"] assert output["permissionDecision"] == "deny" reason = output["permissionDecisionReason"] - assert f"You just tried {tool} before reading. Call #{attempt} since this compaction, refused." in reason + assert f"You just tried {tool} before reading. Call #{attempt} since this deal, refused." in reason assert f"Tristan told you, verbatim:\n{words[0]}\nYou did not." in reason assert ReadThenRepeatedFailure().cite() in reason assert ReadThenRepeatedFailure.title in reason @@ -749,9 +753,9 @@ def test_refusal_counts_names_tool_and_prefills_the_owned_audit( output = json.loads(capsys.readouterr().out)["hookSpecificOutput"] assert output["permissionDecision"] == "deny" assert str(start.audit) in output["permissionDecisionReason"] - start.prepare(CommanderReader(), words) + start.prepare(CommanderReader(), words, StartedDeal()) gated(transcript, "Bash", {}).judge() - assert "Call #1 since this compaction, refused." in capsys.readouterr().out + assert "Call #1 since this deal, refused." in capsys.readouterr().out def test_the_audit_answers_each_shown_prompt_by_its_own_words_with_an_action(): @@ -845,3 +849,18 @@ def test_a_subagent_compaction_deals_the_subagent_not_its_session(tmp_path, comm assert main.audit.exists() and main.record.read_bytes() == dealt sub = DeckStart.of({**hook_record(transcript, "SessionStart"), "agent_id": "a9"}) assert sub.record.exists() and str(sub.audit) in result.stdout + + +def test_a_compaction_deal_opens_with_when_it_compacted(tmp_path, commanding_session): + """A fresh context cannot remember compacting, so the deal says when, from the transcript's + own compact boundary: claude-code called real compactions a gate bug four times.""" + transcript = tmp_path / "session.jsonl" + transcript.write_text(json.dumps({ + "type": "system", "subtype": "compact_boundary", "content": "Conversation compacted", + "uuid": "0460bd61-a862-449d-8421-4452811929fa", "timestamp": "2026-10-30T17:29:04.391Z", + }) + "\n") + start = DeckStart.decode({**hook_record(transcript, "SessionStart"), "source": "compact"}) + said = start.take() + at = datetime(2026, 10, 30, 17, 29, 4, 391000, UTC).astimezone().isoformat(timespec="seconds") + assert said[0].startswith(CompactedDeal(at).reason()) + assert start.current().trigger == CompactedDeal(at) diff --git a/tests/test_dealt_read_identity.py b/tests/test_dealt_read_identity.py index 5c2b5fda55..378cefa7ea 100644 --- a/tests/test_dealt_read_identity.py +++ b/tests/test_dealt_read_identity.py @@ -9,7 +9,9 @@ from pathlib import Path from agent_comms.context_reading import ContextUsage -from agent_comms.rule_deal import DealRecord, DealtRead, DealtSource, Open, OpenedReads, ReadSpan, Unread +from agent_comms.rule_deal import ( + DealRecord, DealtRead, DealtSource, Open, OpenedReads, ReadSpan, StartedDeal, Unread, +) from agent_comms.field_codec import FieldCodec from agent_comms.pi_rule_gate import PiRedeal from agent_comms_hooks.skill import FleetReader @@ -18,7 +20,7 @@ def test_identical_content_under_a_new_runtime_needs_no_reread(commander_bus): deal = PiRedeal.of(commander_bus, commander_bus.registry.require("unit-x")) - deal.redeal() + deal.redeal(StartedDeal()) answer_deal(deal) record = deal.current() moved = tuple( @@ -32,18 +34,18 @@ def test_identical_content_under_a_new_runtime_needs_no_reread(commander_bus): def test_answers_and_reads_outlive_everything_but_lost_context(commander_bus): deal = PiRedeal.of(commander_bus, commander_bus.registry.require("unit-x")) - deal.redeal() + deal.redeal(StartedDeal()) answer_deal(deal) read = deal.current().read_by(deal) deal.audit.write_text(deal.audit.read_text(encoding="utf-8"), encoding="utf-8") assert deal.current().read_by(deal) == read - deal.deal(FleetReader, ()) + deal.deal(FleetReader, (), StartedDeal()) assert not deal.audit.exists() and deal.current().read_by(deal) == () # a fresh confession def test_a_changed_file_is_owed_again_and_only_that_file(commander_bus): deal = PiRedeal.of(commander_bus, commander_bus.registry.require("unit-x")) - deal.redeal() + deal.redeal(StartedDeal()) record = deal.current() changed, *kept = record.paths for path in kept: @@ -58,7 +60,7 @@ def test_a_refresh_passed_while_reading_the_deal_is_answered_by_it(commander_bus """bus 45261: the first tool call after a Pi unit finished its deal (a comms_send) dealt 16 files again, because reading them had filled the context past the 66%-left refresh.""" deal = PiRedeal.of(commander_bus, commander_bus.registry.require("unit-x")) - deal.redeal() + deal.redeal(StartedDeal()) deal.observe_context(ContextUsage(40, 100)) answer_deal(deal) dealt = deal.current().dealt diff --git a/tests/test_pi_rule_gate.py b/tests/test_pi_rule_gate.py index 2d11a13695..699b02a7a9 100644 --- a/tests/test_pi_rule_gate.py +++ b/tests/test_pi_rule_gate.py @@ -124,7 +124,7 @@ def test_a_compacted_pi_thread_reads_every_dealt_rule_then_writes_its_audit( bash.judge_under(unit_deal) call("read", {"path": "notes.md"}).judge_under(unit_deal) events.CompactionEnd().deal_rules(unit_deal) # the next compaction - with pytest.raises(RulesUnread, match="Call #1 since this compaction"): + with pytest.raises(RulesUnread, match="Call #1 since this deal"): bash.judge_under(unit_deal) @@ -193,3 +193,46 @@ def test_a_bus_with_no_commander_project_deals_a_pi_unit_the_fleet_rules(comms, assert deal.current().dealt == tuple(path.resolve() for path in FleetReader.sources()) +def test_every_refusal_opens_with_why_its_deal_was_dealt(unit_deal): + """Start, compaction and refresh each say their own cause first, so a fresh context reads + why it was dealt instead of diagnosing the gate (rules/after-compaction.md).""" + import re + + from agent_comms.rule_deal import RefreshDeal, StartedDeal + + bash = call("bash", {"command": "ls"}) + unit_deal.start() + with pytest.raises(RulesUnread, match=re.escape(StartedDeal().reason())): + bash.judge_under(unit_deal) + events.CompactionEnd().deal_rules(unit_deal) + with pytest.raises(RulesUnread, match="Re-dealt on purpose: your context compacted at "): + bash.judge_under(unit_deal) + answer_deal(unit_deal) + unit_deal.observe_context(ContextUsage(35, 100)) + with pytest.raises(RulesUnread, match=re.escape(RefreshDeal(35).reason())): + bash.judge_under(unit_deal) + + +def test_the_dealt_commander_skill_owes_an_answer_like_a_rule(commander_bus): + """The commander skill is dealt and read, so it fills the unanswered window like a rule: + on 2026-10-30 claude-nra read it owing nothing and broke its rotation within the hour.""" + import sys + + from agent_comms.rule_deal import ReadSpan, StartedDeal + + deal = PiRedeal.of(commander_bus, commander_bus.registry.require("claude-code")) + deal.redeal(StartedDeal()) + record = deal.current() + skill = CommanderPackage.skill().resolve() + assert skill.stem in record.due.rules + order = list(record.dealt) + before = [source for source in order[:order.index(skill)] if source.stem in record.due.rules] + unanswered = {*before[-(Unengaged.batch - 1):], skill} + answered = [] + for source in order[:order.index(skill) + 1]: + deal.record_read(source, ReadSpan(1, sys.maxsize, sys.maxsize)) + if source.stem in record.due.rules and source not in unanswered: + answered.append(engaged(source.stem, source)) + deal.audit.write_text(json.dumps({"rules": answered, "owner_words": []})) + with pytest.raises(RulesUnread, match=f"audit's entry for {skill.stem} does not answer it"): + deal.judge_read("read", order[order.index(skill) + 1]) diff --git a/tests/test_selected_tool_broker.py b/tests/test_selected_tool_broker.py index 0ddd99582f..7171f75399 100644 --- a/tests/test_selected_tool_broker.py +++ b/tests/test_selected_tool_broker.py @@ -377,10 +377,11 @@ async def test_a_dealt_rule_gate_refuses_the_selected_write_before_its_owner_act """The selected write rides the shared gated transport: an unread deal refuses it before the owner callback could claim or write anything.""" from agent_comms.pi_rule_gate import PiRuleGate, RulesUnread + from agent_comms.rule_deal import StartedDeal from agent_comms_hooks.skill import FleetReader gate = PiRuleGate(tmp_path / "scratch", tmp_path) - gate.deal(FleetReader, ()) + gate.deal(FleetReader, (), StartedDeal()) token = secrets.token_hex(32) acted: list[broker.SelectedToolRequest] = [] server = broker.SelectedToolSocket(