Retire the old build spec, and point every reference at the architecture (M0.1)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s

Development Plan M0.1. Every section reference in code, tests, specs/,
the example config, the tuning guide, and the CLAUDE.md and README files
now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old
spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps
it). The plan's old-to-new table goes with it.

Mostly mechanical, with a per-site choice where one old section split:
A4 became L2 where it meant stop/abandon/the loop and L3 where it meant
approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2
became D4 for outbound Gitea and publishing and D5 for review intake;
'A3 step N' became the matching step of L8's boot sequence. Comments
describing behaviour the architecture drops (planning always escalating,
thresholds tuned at an old build step) now describe the current code and
point at the Development Plan.

docs/Architecture.md gains one line the rewrite missed: intake outages
delay but never fail work (old B2), now in L5, where the poller and
Matrix code cite it.

Comments and docs only; no behaviour change.

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-10-01 00:17:48 +00:00
1 parent 890708594b
commit 1ad0b2147d
73 files changed
+259 -1786

No files matched your search

+29 -29
View File
@@ -1,4 +1,4 @@
"""Control loop and state machine (spec A4, B1 subset).
"""Control loop and state machine (architecture L2, L3; retired by M2.6).
Gating is structural, not advisory: the agent's only actions are ``SubmitPlan``
(posts the plan for approval and ends the agent's turn) and ``AskHuman``
@@ -8,7 +8,7 @@ arrives over transport. Every position change is checkpointed through the state
store before the loop continues, so a restart lands mid-gate, not mid-air.
All of it runs on one worker (nisse_dev.worker), never on the thread that
delivered a message. The one exception is `stop` (A4), which has to reach
delivered a message. The one exception is `stop` (L2), which has to reach
work that is already running: the transport's thread sets the cancel flag
directly, and the worker records the stop once the work has returned.
"""
@@ -57,12 +57,12 @@ TRANSITIONS = {
(AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING),
(AWAIT_MILESTONE_SIGNOFF, EXECUTING),
(EXECUTING, PR_OPEN),
# PR acceptance is the milestone's final gate (B1): a merged PR is the
# PR acceptance is the milestone's final gate (D5): a merged PR is the
# durable record, so completion returns to planning or to idle rather
# than passing through a separate FINISHED state.
(PR_OPEN, MILESTONE_PLANNING),
(PR_OPEN, IDLE),
# abandon (A4, B2): the operator may end the task from any waiting state.
# abandon (L2): the operator may end the task from any waiting state.
# (AWAIT_INPUT may go anywhere already.)
(AWAIT_ARCH_SIGNOFF, IDLE),
(AWAIT_MILESTONE_SIGNOFF, IDLE),
@@ -91,7 +91,7 @@ class PlanningError(RuntimeError):
class PlanningStopped(RuntimeError):
"""The operator said `stop` (A4) before a planning turn could run."""
"""The operator said `stop` (L2) before a planning turn could run."""
@dataclass(frozen=True)
@@ -193,7 +193,7 @@ def _project_name(text: str) -> str:
def _fresh_data() -> dict[str, Any]:
return {
# Latest revision per gate; revision numbers themselves run across
# the whole work item (last_rev), so rev-N names one plan (A4).
# the whole work item (last_rev), so rev-N names one plan (L3).
"revs": {},
"last_rev": 0,
# The revision last put up for sign-off: a reply that names none
@@ -205,13 +205,13 @@ def _fresh_data() -> dict[str, Any]:
"pending": None,
"milestone": 1,
"task_id": None,
"roles": None, # A11 mapping, pinned at task start
"roles": None, # L9 mapping, pinned at task start
# What each pinned alias resolved to, discovered from LiteLLM. An
# alias is free to point somewhere else tomorrow, so the transcript
# has to say what actually answered (A11, A16).
# has to say what actually answered (L9).
"models": None,
"pr": None, # the open PR for the current milestone, once published
# Pointer to the Gitea plan record (Part H): the state store holds
# Pointer to the Gitea plan record (D2): the state store holds
# only this, so gate state can be re-derived from Gitea after a
# state-volume loss.
"board": None,
@@ -244,7 +244,7 @@ class Orchestrator:
self._executor = executor
self._publisher = publisher
self._operators = set(operators)
# Per-channel allowlists (A4): a Matrix MXID and a Gitea login
# Per-channel allowlists (L3): a Matrix MXID and a Gitea login
# are different names for the same person, and neither implies
# the other.
self._gitea_operators = set(gitea_operators)
@@ -276,14 +276,14 @@ class Orchestrator:
transport.on_message(self._handle)
def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None:
# A3 step 5 runs before the generic sweep: an interrupted delegation is
# L8 boot step 5 runs before the generic sweep: an interrupted delegation is
# resumed or discarded by inspecting the branch and the log, and only a
# genuinely unclean cut is left for the sweep below to gate on.
if self._delegate is not None:
with self._long_work():
self._delegate.recover(self._transport.send_message)
# The publisher owns the external-truth query for its own intents, so
# a configured publisher always contributes its reconciler (A3).
# a configured publisher always contributes its reconciler (L8).
registered: dict[str, Reconciler] = dict(reconcilers or {})
if self._publisher is not None:
registered = {**self._publisher.reconcilers(), **registered}
@@ -317,7 +317,7 @@ class Orchestrator:
pending={"kind": "boot", "interrupted": state.state},
)
elif state.state == AWAIT_INPUT and self._data.get("pending"):
# Waiting on the operator is a committed state (A3 step 8):
# Waiting on the operator is a committed state (L8 boot step 4):
# nothing was in flight, and the question and what answering it
# resumes are durable. Ask it again rather than replacing it.
pending = self._data["pending"]
@@ -326,7 +326,7 @@ class Orchestrator:
+ pending.get("prompt", "my last question still stands")
)
elif state.state == EXECUTING and (published := self._published_pr()):
# A3 step 4: Gitea shows this milestone's PR open, so it was
# L8 boot step 3: Gitea shows this milestone's PR open, so it was
# published and only PR_OPEN went unrecorded. Adopt the PR rather
# than ask, and never publish it again. Nothing watched it yet,
# so every comment on it is new.
@@ -347,7 +347,7 @@ class Orchestrator:
"review comments"
)
elif state.state == PR_OPEN:
# Waiting on review is a committed state like a gate (A3 step
# Waiting on review is a committed state like a gate (L8 boot step
# 8): resume, and watch the PR again from its cursor.
pr = self._data.get("pr") or {}
self._watch_pr(pr)
@@ -385,7 +385,7 @@ class Orchestrator:
)
else:
# Interrupted mid-turn (planning, executing, unanswered question):
# never blind-resume (A3.6).
# never blind-resume (L8 boot step 5).
self._await_input(
cause=f"boot: restarted in {state.state}",
prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}",
@@ -400,7 +400,7 @@ class Orchestrator:
text = msg.text.strip()
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).
# work is what it has to reach (L2).
if self._worker.busy:
self._cancel.set()
self._worker.submit(lambda: self._finish_stop(msg))
@@ -478,7 +478,7 @@ class Orchestrator:
def _halt_for_stop(
self, *, what: str, note: str, next_step: str, pending: dict[str, Any]
) -> None:
"""A4: stopped work moves to AWAIT_INPUT, and what happens next is the
"""L2: stopped work moves to AWAIT_INPUT, and what happens next is the
operator's call. The prompt asks why, because the model should hear
the reason before it tries again."""
self._halted_by_stop = True
@@ -494,7 +494,7 @@ class Orchestrator:
def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None:
# Every operator message enters the canonical transcript, so planning
# turns see feedback and answers as conversation rather than as
# out-of-band parameters (A10).
# out-of-band parameters (L7).
self._store.append_event(
UserMessage(
text=(msg.original or msg.text).strip(),
@@ -555,7 +555,7 @@ class Orchestrator:
def _begin_project(self, project: str) -> None:
task_id = f"{project}-{uuid.uuid4().hex[:8]}"
# Pin the role mapping now (A11): a mid-task config change must not
# Pin the role mapping now (L9): a mid-task config change must not
# split one task across two planners.
pinned = self._roles.pinned() if self._roles is not None else None
resolved = (
@@ -666,7 +666,7 @@ class Orchestrator:
def _apply_gate_reply(
self, *, sender: str, text: str, channel: str, about: int | None = None
) -> None:
"""Shared by every approval source (A4). The allowlist is per channel:
"""Shared by every approval source (L3). The allowlist is per channel:
a Matrix MXID and a Gitea login are different names, and holding one
says nothing about holding the other."""
gate_data = self._data["gate"]
@@ -771,7 +771,7 @@ class Orchestrator:
def _echo_outcome(self, deciding_channel: str, text: str) -> None:
"""A gate satisfied on one channel is echoed to the other, so neither
record silently disagrees about what was approved (A4)."""
record silently disagrees about what was approved (L3)."""
board_state = self._data.get("board")
if deciding_channel != "gitea" and self._board is not None and board_state:
self._board.echo(board_state["issue"], text)
@@ -792,7 +792,7 @@ class Orchestrator:
# Which comment put which revision up: a Gitea reply binds to the
# latest plan posted before it, since comment IDs only grow.
board_state.setdefault("posts", []).append([post.comment_id, rev])
# Approvals can arrive on the issue as well as over transport (A4),
# Approvals can arrive on the issue as well as over transport (L3),
# so watch it for as long as the gate stands. A new revision skips
# what came before it: an early "approve" must not approve it.
board_state["last_comment"] = self._watch(
@@ -801,7 +801,7 @@ class Orchestrator:
self._save(f"{gate_name} rev-{rev} posted to Gitea")
def handle_plan_comment(self, comment: Any) -> None:
"""A comment on the project issue may satisfy an open plan gate (A4).
"""A comment on the project issue may satisfy an open plan gate (L3).
Approvals bind to a revision here exactly as over transport, and the
first valid approval on either channel wins. Called from the poller
@@ -837,12 +837,12 @@ class Orchestrator:
)
def handle_review_comment(self, comment: Any) -> None:
"""React to a PR review comment (B2). Called from the poller thread.
"""React to a PR review comment (D5). Called from the poller thread.
Comment authors are checked against the Gitea operator allowlist:
a PR comment is an instruction channel into an agent that writes
code, so it is operator-scoped like any other (A4, and the same
reasoning C4 applies to tickets).
code, so it is operator-scoped like any other (L11, and the same
reasoning O5 applies to tickets).
"""
self._worker.call(
lambda: self._settled(
@@ -991,7 +991,7 @@ class Orchestrator:
)
def _abandon(self, sender: str) -> None:
"""End the task on the operator's word (A4: continue, re-plan, or
"""End the task on the operator's word (L2: continue, re-plan, or
abandon), closing its Gitea records. Only reached from a waiting
state: while work runs the reply is "busy; say 'stop'" instead.
@@ -1196,7 +1196,7 @@ class Orchestrator:
)
def _resume_after_restart(self, pending: dict[str, Any], text: str) -> None:
"""Answer to the question a restart asks (A3 step 7). Never a blind
"""Answer to the question a restart asks (L8 boot step 5). 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 = command_form(text)