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

+27 -12
View File
@@ -23,7 +23,14 @@ from typing import Any, Protocol
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import GateOpened, PlanSubmitted, UserMessage
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
from nisse_dev.gates import (
Approved,
Gate,
Ignored,
PlanRevision,
Rejected,
command_form,
)
from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaError
from nisse_dev.llm import RoleMap
@@ -165,6 +172,12 @@ class StubAgent:
return SubmitPlan(f"[stub] {gate} plan for {project}{notes}")
def _project_name(text: str) -> str:
"""The name in 'use <name>', as written: only the command word is
case-insensitive."""
return text.strip().rstrip(".!")[len("use") :].strip()
def _fresh_data() -> dict[str, Any]:
return {
"revs": {},
@@ -331,7 +344,7 @@ class Orchestrator:
if msg.sender not in self._operators:
return
text = msg.text.strip()
if text == "stop":
if command_form(text) == "stop":
# Straight to the flag, not behind the work in the queue: the
# work is what it has to reach (A4).
if self._worker.busy:
@@ -434,9 +447,10 @@ class Orchestrator:
state = self._store.get_state()
current = state.state if state else IDLE
text = msg.text.strip()
if text == "status":
command = command_form(text)
if command == "status":
self._send_status(current)
elif text == "abandon":
elif command == "abandon":
# Like status, a command in every waiting state: it takes
# precedence over reading the word as an answer or feedback.
if current == IDLE:
@@ -444,8 +458,8 @@ class Orchestrator:
else:
self._abandon(msg.sender)
elif current == IDLE:
if text.startswith("use "):
self._begin_project(text[4:].strip())
if command.startswith("use "):
self._begin_project(_project_name(text))
else:
self._transport.send_message("no active project; say 'use <project>'")
elif current in (AWAIT_ARCH_SIGNOFF, AWAIT_MILESTONE_SIGNOFF):
@@ -592,7 +606,7 @@ class Orchestrator:
name, rev = gate_data["name"], gate_data["rev"]
milestone = self._data["milestone"]
if gate_data["approved"]:
if text == f"start milestone {milestone}":
if command_form(text) == f"start milestone {milestone}":
self._transition(
EXECUTING, cause=f"operator command: start milestone {milestone}"
)
@@ -941,14 +955,15 @@ class Orchestrator:
)
def _handle_pr_open(self, text: str) -> None:
if text == "next milestone":
command = command_form(text)
if command == "next milestone":
self._unwatch("review")
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
self._data["gate"] = None
self._data["pr"] = None
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
self._run_planning_turn(f"milestone-{self._data['milestone']}")
elif text == "project complete":
elif command == "project complete":
self.stop_watching()
self._data = {**_fresh_data()}
self._project = None
@@ -1022,9 +1037,9 @@ class Orchestrator:
if pending is None:
# Reached only if state was forced externally or a pending item
# was lost; starting over must still work from here.
if text.startswith("use "):
if command_form(text).startswith("use "):
self.stop_watching()
self._begin_project(text[4:].strip())
self._begin_project(_project_name(text))
return
self._transport.send_message(
"waiting for direction, but I have no pending item to resume; "
@@ -1061,7 +1076,7 @@ class Orchestrator:
"""Answer to the question a restart asks (A3 step 7). Never a blind
resume: 'continue' re-runs the interrupted planning turn, or returns
an interrupted milestone to its approved plan to be started again."""
choice = text.strip().rstrip(".!").lower()
choice = command_form(text)
if choice not in ("continue", "re-plan", "replan"):
self._data["pending"] = pending
self._transport.ask_question(_BOOT_CHOICES)