From 8be4fd6d8e53d40f4830af7a5dd7ede65c5c620f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 18 Oct 2026 23:53:46 -0400 Subject: [PATCH 1/5] AuthorizedAct family: claude-ai asking the user whether to send, enforce, strike, reassign or fix is refused, authorized; act Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/connector.py | 12 +++++--- src/agent_comms/todos.py | 55 ++++++++++++++++++++++++++++++++-- src/agent_comms/tool_caller.py | 6 ++-- src/agent_comms/tools.py | 2 +- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 1d63d9668b..2492808814 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -57,11 +57,15 @@ class DirectorCaller(ThreadCaller): """claude-ai speaks for the owner: each message it sends a thread is a directive, recorded as a request when that thread holds owner requests.""" - def require_directive(self, comms: Comms, body: str) -> None: - """A directive passes DirectiveText and needs this caller's rules review, drawn, - answered and under 20 minutes old.""" + def require_directive(self, comms: Comms, target: str, body: str) -> None: + """A directive passes DirectiveText, and its TO_USER families when the target is the + human user, and needs this caller's rules review, drawn, answered and under 20 minutes + old.""" + readers = (comms.registry.require(target),) if target in comms.registry else () + also = tuple(family for thread in readers for families in thread.role.human(DirectiveText.TO_USER) + for family in families) due = tuple(rule.line() for rule in RulesDue.violations(comms, self.name)) - if refused := (*DirectiveText.refused(body), *due): + if refused := (*DirectiveText.refused(body, *also), *due): raise ValueError("Directive refused, nothing sent: " + " ".join(refused)) def sent(self, comms: Comms, message: Message) -> None: diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index fc65ff4386..5eac191087 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -753,16 +753,65 @@ def found(cls, text: str) -> list[str]: return super().found(cls.SLOP.sub(' ', text)) if named else ['no defect named'] +class AuthorizedAct(DeclaredFamily, affix="Act"): + """An act claude-ai is authorized to take without asking: a question to the user asking + whether to take it costs his attention and stalls the fleet. Product meaning and costly or + irreversible acts are not members, so questions about them still reach him.""" + + ASKS: ClassVar[str] = (r"\b(?:do you want me to|want me to|should i|shall i|may i|can i" + r"|ok(?:ay)? (?:for me )?to|permission to)\s+") + words: ClassVar[tuple[str, ...]] + + @classmethod + def asked(cls, text: str) -> bool: + return re.search(cls.ASKS + rf"(?:{'|'.join(cls.words)})\b[^?\n]*\?", text, re.I) is not None + + +class SendAct(AuthorizedAct): + words = ("send", "post", "forward", "relay") + + +class EnforceAct(AuthorizedAct): + words = ("enforce",) + + +class StrikeAct(AuthorizedAct): + words = ("strike",) + + +class ReassignAct(AuthorizedAct): + words = ("reassign", "move") + + +class FixAct(AuthorizedAct): + words = ("fix", "repair", "patch") + + +class PermissionText(OrderWords): + """A message to the user never asks whether to take an act claude-ai is authorized for.""" + + refusal = "authorized; act: {1} is yours to do, so do it and report." + + @classmethod + def found(cls, text: str) -> list[str]: + spoken = cls.CITED.sub(' ', text) + return [act.declared_name for act in AuthorizedAct.members_with(AuthorizedAct) + if act.asked(spoken)] + + class DirectiveText: """What the commander and the owner's voice send units: order send, order say and the - connector's send refuse a text failing any of these.""" + connector's send refuse a text failing any of these. The owner's voice writing to the user + also passes TO_USER.""" OUTGOING: ClassVar[tuple[type[OrderWords], ...]] = ( FairText, HierarchyText, PlainText, OrderText, DefectText) + TO_USER: ClassVar[tuple[type[OrderWords], ...]] = (PermissionText,) @classmethod - def refused(cls, text: str) -> tuple[str, ...]: - return tuple(refused for family in cls.OUTGOING if (refused := family.refused(text))) + def refused(cls, text: str, *also: type[OrderWords]) -> tuple[str, ...]: + return tuple(refused for family in (*cls.OUTGOING, *also) + if (refused := family.refused(text))) class OrderRun(DeclaredFamily, affix="OrderRun"): diff --git a/src/agent_comms/tool_caller.py b/src/agent_comms/tool_caller.py index 3a7d2c30e2..4910cfc890 100644 --- a/src/agent_comms/tool_caller.py +++ b/src/agent_comms/tool_caller.py @@ -46,7 +46,7 @@ def subject(self, named: str) -> str: ... def may_reconfigure(self, comms: Comms, name: str) -> bool: ... @abstractmethod - def require_directive(self, comms: Comms, body: str) -> None: + def require_directive(self, comms: Comms, target: str, body: str) -> None: """A caller whose sends direct units refuses a body its directive checks refuse.""" @abstractmethod @@ -76,7 +76,7 @@ def subject(self, named: str) -> str: def may_reconfigure(self, comms: Comms, name: str) -> bool: return not comms.registry.status(name).active - def require_directive(self, comms: Comms, body: str) -> None: + def require_directive(self, comms: Comms, target: str, body: str) -> None: """A caller with no thread gives no directive.""" def sent(self, comms: Comms, message: Message) -> None: @@ -104,7 +104,7 @@ def may_reconfigure(self, comms: Comms, name: str) -> bool: return (not comms.registry.status(name).active or comms.registry.canonical_name(name) == comms.registry.canonical_name(self.name)) - def require_directive(self, comms: Comms, body: str) -> None: + def require_directive(self, comms: Comms, target: str, body: str) -> None: """A unit's or a script's send is a message, not a directive.""" def sent(self, comms: Comms, message: Message) -> None: diff --git a/src/agent_comms/tools.py b/src/agent_comms/tools.py index ea27ec719a..58dda7c494 100644 --- a/src/agent_comms/tools.py +++ b/src/agent_comms/tools.py @@ -1075,7 +1075,7 @@ class CommsSendTool(ToolRequest): ) def apply(self, comms: Comms, caller: ToolCaller) -> MessagePublished: - caller.require_directive(comms, self.body) + caller.require_directive(comms, self.target, self.body) body = VerdictAudience(self.audience).addressed(self.target, self.body) result = comms.messaging.send(self.sender, self.target, body, self.type, report=self.report) caller.sent(comms, result.message) From aa4e787bee389982e5bb47adf27fbe1c82a7e037 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 18 Oct 2026 23:58:56 -0400 Subject: [PATCH 2/5] Permission questions to the user are refused; product questions pass Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/connector.py | 8 ++++++-- tests/test_commander_problem_orders.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 2492808814..50c91c9b24 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -62,8 +62,12 @@ def require_directive(self, comms: Comms, target: str, body: str) -> None: human user, and needs this caller's rules review, drawn, answered and under 20 minutes old.""" readers = (comms.registry.require(target),) if target in comms.registry else () - also = tuple(family for thread in readers for families in thread.role.human(DirectiveText.TO_USER) - for family in families) + also = tuple( + family + for thread in readers + for families in thread.role.human(DirectiveText.TO_USER) + for family in families + ) due = tuple(rule.line() for rule in RulesDue.violations(comms, self.name)) if refused := (*DirectiveText.refused(body, *also), *due): raise ValueError("Directive refused, nothing sent: " + " ".join(refused)) diff --git a/tests/test_commander_problem_orders.py b/tests/test_commander_problem_orders.py index d2e674b985..ebd967a0ad 100644 --- a/tests/test_commander_problem_orders.py +++ b/tests/test_commander_problem_orders.py @@ -871,6 +871,31 @@ def todo_row(comms, problem): return db.execute('SELECT * FROM todo WHERE id=?', (problem,)).fetchone() +def test_claude_ai_permission_seeking_to_the_user_is_refused_and_product_questions_pass(commander, tmp_path): + """claude-ai asking the user whether to take an act it is authorized for is refused before + anything is sent; a question about product meaning still reaches the user, and the same + permission question to a unit is a message to that unit.""" + from agent_comms.connector import CALLER + from agent_comms.rule_reviews import AnsweredRuleReviewEntry, DrawnRuleReviewEntry, RuleReviews + from agent_comms.thread_identity import UserThreadRole + comms, _ = commander + comms.registry.declare(comms.registry.new_thread('claude-ai', frozenset(), str(tmp_path), created_at=3.0)) + comms.registry.declare(comms.registry.new_thread( + 'user', frozenset(), str(tmp_path), role=UserThreadRole, created_at=4.0)) + RuleReviews(comms.root).write('claude-ai', DrawnRuleReviewEntry(('Declare each fact once',), 1)) + RuleReviews(comms.root).write('claude-ai', AnsweredRuleReviewEntry( + 1, 'This tick moves rules.py:47 into one typed Core record that Core owns.')) + def direct(target, body): + CommsSendTool.invoke(comms, {'from': 'claude-ai', 'to': target, 'body': body}, caller=CALLER) + return comms.bus.log.latest_sequence() + sent = comms.bus.log.latest_sequence() + with pytest.raises(ValueError, match=r"authorized; act: \['send'\] is yours to do"): + direct('user', 'Want me to send the order?') + assert comms.bus.log.latest_sequence() == sent + assert direct('user', 'Should P174 keep the old ledger format?') == sent + 1 + assert direct('worker', 'Want me to send the order?') == sent + 2 + + def test_goal_stays_open_while_problems_pend(commander): """Delivering a problem never completes the Goal while another assigned problem pends, even one stored under a stale generation; a revised order for one keeps the other's order and deadline.""" From 09a19443efcd3036374676dcc0c012967a537e21 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 00:00:52 -0400 Subject: [PATCH 3/5] AuthorizedAct.asked returns a bool Src-Budget: 57 Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/todos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index 5eac191087..9511a2d49f 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -764,7 +764,7 @@ class AuthorizedAct(DeclaredFamily, affix="Act"): @classmethod def asked(cls, text: str) -> bool: - return re.search(cls.ASKS + rf"(?:{'|'.join(cls.words)})\b[^?\n]*\?", text, re.I) is not None + return bool(re.search(cls.ASKS + rf"(?:{'|'.join(cls.words)})\b[^?\n]*\?", text, re.I)) class SendAct(AuthorizedAct): From 3e6beafaae1bc596b00b95f64875e7beee38ddb7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 00:13:01 -0400 Subject: [PATCH 4/5] Every act claude-ai asked about is an AuthorizedAct; the recipient comes from the target register, restart, install, merge, kill/stop, archive, retry/clear and start are members; ASKS recognises should we, would you like me to and let me know if you want me to. The registry membership probe is replaced by the target's direct names. Src-Budget: 91 Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/connector.py | 8 +++--- src/agent_comms/todos.py | 40 ++++++++++++++++++++++++-- tests/test_commander_problem_orders.py | 16 +++++++---- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/agent_comms/connector.py b/src/agent_comms/connector.py index 50c91c9b24..440032c367 100644 --- a/src/agent_comms/connector.py +++ b/src/agent_comms/connector.py @@ -33,6 +33,7 @@ from aiohttp import web from pydantic import JsonValue +from .channel_targets import CommandTarget from .cli_commands import DeclaredOptions, option from .cli_protocol import CoreError from .comms import Comms, client_wire @@ -58,14 +59,13 @@ class DirectorCaller(ThreadCaller): request when that thread holds owner requests.""" def require_directive(self, comms: Comms, target: str, body: str) -> None: - """A directive passes DirectiveText, and its TO_USER families when the target is the + """A directive passes DirectiveText, and its TO_USER families when the target names the human user, and needs this caller's rules review, drawn, answered and under 20 minutes old.""" - readers = (comms.registry.require(target),) if target in comms.registry else () also = tuple( family - for thread in readers - for families in thread.role.human(DirectiveText.TO_USER) + for name in CommandTarget.named(target).direct_names + for families in comms.registry.require(name).role.human(DirectiveText.TO_USER) for family in families ) due = tuple(rule.line() for rule in RulesDue.violations(comms, self.name)) diff --git a/src/agent_comms/todos.py b/src/agent_comms/todos.py index e5eccf84a4..e647f22656 100644 --- a/src/agent_comms/todos.py +++ b/src/agent_comms/todos.py @@ -758,13 +758,15 @@ class AuthorizedAct(DeclaredFamily, affix="Act"): whether to take it costs his attention and stalls the fleet. Product meaning and costly or irreversible acts are not members, so questions about them still reach him.""" - ASKS: ClassVar[str] = (r"\b(?:do you want me to|want me to|should i|shall i|may i|can i" - r"|ok(?:ay)? (?:for me )?to|permission to)\s+") + ASKS: ClassVar[str] = ( + r"\b(?:(?:do you want me to|want me to|would you like me to|should (?:i|we)|shall i" + r"|may i|can i|ok(?:ay)? (?:for me )?to|permission to)\s+(?:{acts})\b[^?\n]*\?" + r"|let me know if you want me to\s+(?:{acts})\b)") words: ClassVar[tuple[str, ...]] @classmethod def asked(cls, text: str) -> bool: - return bool(re.search(cls.ASKS + rf"(?:{'|'.join(cls.words)})\b[^?\n]*\?", text, re.I)) + return bool(re.search(cls.ASKS.format(acts="|".join(cls.words)), text, re.I)) class SendAct(AuthorizedAct): @@ -787,6 +789,38 @@ class FixAct(AuthorizedAct): words = ("fix", "repair", "patch") +class RegisterAct(AuthorizedAct): + words = ("register",) + + +class RestartAct(AuthorizedAct): + words = ("restart",) + + +class InstallAct(AuthorizedAct): + words = ("install",) + + +class MergeAct(AuthorizedAct): + words = ("merge",) + + +class KillAct(AuthorizedAct): + words = ("kill", "stop") + + +class ArchiveAct(AuthorizedAct): + words = ("archive",) + + +class RetryAct(AuthorizedAct): + words = ("retry", "clear") + + +class StartAct(AuthorizedAct): + words = ("start",) + + class PermissionText(OrderWords): """A message to the user never asks whether to take an act claude-ai is authorized for.""" diff --git a/tests/test_commander_problem_orders.py b/tests/test_commander_problem_orders.py index 75293ee756..2b640fecbd 100644 --- a/tests/test_commander_problem_orders.py +++ b/tests/test_commander_problem_orders.py @@ -893,10 +893,11 @@ def todo_row(comms, problem): return db.execute('SELECT * FROM todo WHERE id=?', (problem,)).fetchone() -def test_claude_ai_permission_seeking_to_the_user_is_refused_and_product_questions_pass(commander, tmp_path): - """claude-ai asking the user whether to take an act it is authorized for is refused before - anything is sent; a question about product meaning still reaches the user, and the same - permission question to a unit is a message to that unit.""" +def test_claude_ai_permission_seeking_register_or_restart_to_the_user_is_refused_and_product_questions_pass( + commander, tmp_path): + """claude-ai asking the user whether to take an act it is authorized for, in any common way of + asking, is refused before anything is sent; a question about product meaning still reaches the + user, and the same permission question to a unit is a message to that unit.""" from agent_comms.connector import CALLER from agent_comms.rule_reviews import AnsweredRuleReviewEntry, DrawnRuleReviewEntry, RuleReviews from agent_comms.thread_identity import UserThreadRole @@ -911,8 +912,11 @@ def direct(target, body): CommsSendTool.invoke(comms, {'from': 'claude-ai', 'to': target, 'body': body}, caller=CALLER) return comms.bus.log.latest_sequence() sent = comms.bus.log.latest_sequence() - with pytest.raises(ValueError, match=r"authorized; act: \['send'\] is yours to do"): - direct('user', 'Want me to send the order?') + for act, body in (('send', 'Want me to send the order?'), + ('register', 'Should I register claude-ai on the desktop?'), + ('restart', 'Would you like me to restart the connector?')): + with pytest.raises(ValueError, match=rf"authorized; act: \['{act}'\] is yours to do"): + direct('user', body) assert comms.bus.log.latest_sequence() == sent assert direct('user', 'Should P174 keep the old ledger format?') == sent + 1 assert direct('worker', 'Want me to send the order?') == sent + 2 From 4b195a4cda426e9cf6462024a5502ebb60fbc047 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 19 Oct 2026 00:15:30 -0400 Subject: [PATCH 5/5] NamedTarget declares direct_names as a typed property Src-Budget: 94 Co-Authored-By: Claude Opus 5.5 --- src/agent_comms/channel_targets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/agent_comms/channel_targets.py b/src/agent_comms/channel_targets.py index 199b2c5c09..486cf6a779 100644 --- a/src/agent_comms/channel_targets.py +++ b/src/agent_comms/channel_targets.py @@ -97,7 +97,6 @@ def contexts(self, declaration, channel): class NamedTarget(CommandTarget): name: str batchable = False - direct_names = () guidance_scope = "" default_audience = MessageAudience.DIRECT @@ -105,6 +104,10 @@ class NamedTarget(CommandTarget): def names(self) -> tuple[str, ...]: return (self.name,) + @property + def direct_names(self) -> tuple[str, ...]: + return () + @abstractmethod def conversation_key(self, sender: str, snapshot: RegistryProvenance, channel: type[ChannelConversation], direct: type[DMConversation]) -> Conversation: ... @@ -157,7 +160,7 @@ def require_frozen_recipients(self, message, audience): message.role.require_recipients(self, audience) @property - def direct_names(self): + def direct_names(self) -> tuple[str, ...]: return self.names def canonical_name(self, canonical):