Match approvals and commands regardless of case and trailing punctuation
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m15s

A phone keyboard's "Approve" did not match the approval pattern, so it
counted as rejection feedback and threw the plan away; "Stop" did not
reach the cancel flag; other capitalised commands got a hint instead of
acting.

gates.command_form() is how a message reads as a command: case,
surrounding whitespace, inner runs of whitespace and a trailing '.' or
'!' do not matter. The approval gate and every command match in the
orchestrator (stop, status, abandon, use, start milestone, next
milestone, project complete, the restart choices) use it. Only matching
changes: the project name in 'use <name>' keeps its case, and feedback
and answers are recorded and passed on as written. A stale revision is
still refused, and approval still needs the whole message to be the
command.

Closes #64.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-09-29 17:57:34 +00:00
1 parent b893fba342
commit 6afd0cc914
5 files changed
+76 -15

No files matched your search

+9 -1
View File
@@ -50,6 +50,14 @@ class Ignored:
_APPROVE_RE = re.compile(r"^approve(?:\s+(rev-\d+))?$")
def command_form(text: str) -> str:
"""How a message reads as a command: case, surrounding whitespace, runs
of inner whitespace and trailing '.' or '!' do not matter, so a phone's
"Approve." is still an approval. Only for matching; what the operator
wrote is what gets recorded and passed on."""
return " ".join(text.strip().rstrip(".!").split()).lower()
class Gate:
def __init__(self, revision: PlanRevision, operators: AbstractSet[str]) -> None:
self._revision = revision
@@ -61,7 +69,7 @@ class Gate:
if sender not in self._operators:
return Ignored(notice=None)
text = text.strip()
match = _APPROVE_RE.match(text)
match = _APPROVE_RE.match(command_form(text))
if match is None:
return Rejected(feedback=text, approver=sender, channel=channel)
named = match.group(1)