Two gaps found by auditing spec against code, the second created by
7b landing the harness without its boot-time half.
**The harness was not connected to anything.** Only a live test
constructed a Delegation; A12's failure-count trigger still gated to
the human, which A13 describes as the fallback rather than the path.
Consecutive command failures now hand the sub-task to the delegate,
with the failures in the brief so it need not rediscover what the
local model learned the hard way. A usable handback lets the local
loop carry on; anything else escalates, because a cap breach leaves a
half-edited tree.
**A3 step 5 was unimplemented.** A crash mid-delegation failed safe —
boot gated on "restarted mid-EXECUTING" — but always gated, even on
the clean cut the spec says should resume, and left the branch orphaned
because _abandon only runs while the process lives.
- A delegation now records an intent before the branch exists, and
settles it whichever way the delegation ends; an aborted delegation
is a finished one, and leaving it pending would make the next boot
believe work was still in flight
- delegation_verdict implements the clean-cut test as an observable
check, never model self-assessment: settled if a completion follows
the start, absent if the branch was never created, gate on a
tool_call with no recorded result, resume otherwise
- recover() sweeps at boot, announcing a resume with a notice rather
than a question, since a clean cut needs no decision from the
operator, and leaving an unclean one pending so the generic sweep
gates on it
- resume() continues the same delegation rather than starting a
second: no new branch, no second delegation_started, because A10
defines a session as the delegation, not the process lifetime
Honest limit, stated in the code: "the branch sits at the expected
commit" is approximated by the branch existing and carrying the work,
since per-turn shas are not recorded in the log, so a branch rewritten
underneath us would go undetected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- approvals.py: PlanBoard opens one issue per project and posts each
plan revision as a comment carrying its rev tag. The state store
keeps only a pointer, so gate state can be re-derived from Gitea
after a state-volume loss — external truth wins
- Gitea comments are a second approval source: revision binding, the
typed word approve, and first-approval-wins work exactly as over
transport, with a per-channel allowlist because a Gitea login is
not a Matrix MXID
- Outcomes are echoed to the other channel, so the two records cannot
silently disagree about what was approved
- A Gitea outage degrades durability of the record, not the ability
to approve: planning proceeds and Matrix remains a complete channel
- Live test now asserts the server refuses the agent's own merge and
its push to the protected branch. That property was previously
true only because our code did not attempt it; branch protection
makes it structural, and this test fails if the protection is ever
relaxed
- Fixed a race in the process-group test: killing a group is
asynchronous and the grandchild is an orphan, so its exit is not
synchronised with our return; the test now waits for death
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- poller.py: CommentPoller polls PR comments rather than taking
webhooks, keeping the VM outbound-only (B3). On an outage it backs
off exponentially, caps, and retries indefinitely while logging:
polling is pure intake, so an outage delays a reaction but never
fails a task or forces a transition
- prime() marks the existing thread as seen without delivering it, so
starting a watch does not re-address feedback already handled
- Review loop: a comment in PR_OPEN enters the canonical transcript,
the executor responds, and the fix is pushed to the same branch with
a reply on the PR. No intent is recorded for a follow-up push — the
PR already exists, so a repeated push is harmless, unlike opening a
second PR
- Comment authors are checked against a separate Gitea operator
allowlist: a PR comment is an instruction channel into an agent that
writes code, so it is operator-scoped, and a Gitea login is not a
Matrix MXID (A4 per-channel allowlists)
- Orchestrator now holds a lock: transport and the poller deliver on
separate threads, and state transitions must not interleave
- Backoff timings are injectable, so the outage path is tested in
milliseconds instead of being untestable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the B1 loop past EXECUTING, and answers the question raised
reviewing #6: PR acceptance is a milestone's final gate, so there is
no separate FINISHED state — completion returns to milestone planning
or to IDLE.
- publish.py: MilestonePublisher commits the work on a task-namespaced
branch, pushes, and opens a PR carrying the approved plan. Intent is
recorded before the first externally visible act, so a crash
anywhere in the sequence leaves a durable record and boot
reconciliation asks Gitea whether the PR exists rather than opening
a second one
- Nothing to commit is reported, not raised: the executor may
legitimately conclude there was nothing to change
- control.py: PR_OPEN state with EXECUTING -> PR_OPEN and
PR_OPEN -> {MILESTONE_PLANNING, IDLE}; operator commands
'next milestone' and 'project complete'
- A configured publisher always contributes its own reconciler at
boot, so an interrupted publish is settled against external truth
without the caller having to remember to register it
- A publish failure gates to the operator rather than retrying into a
possible duplicate
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while chasing a flaky live test, but the flakiness was a real
bug: only the ask_human path recorded a pending item, so AWAIT_INPUT
reached any other way — planning failure, stopped milestone, boot
reconciliation — refused every operator reply with "no pending
question to answer" and the agent could never be recovered. The
operator had no way back short of wiping state.
Every path into AWAIT_INPUT now records what a reply should resume,
and answering dispatches on it: planning failures and questions
resume planning, a stopped milestone returns to its still-approved
gate, and the fallback says what commands work instead of
dead-ending. Two regression tests cover the recovery paths.
The live test also scripted a fixed approve/approve/start sequence,
which a real model breaks by legitimately calling ask_human at a
gate. It now plays the operator adaptively, responding to whatever
the agent actually did. Five consecutive live runs pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- tools.py: four flat tools (read_file, write_file, list_dir,
run_command) per A15's reduced manifest. Deliberately unsandboxed:
the VM is the trust boundary (B3) and tool use is auto-approved
inside an approved unit of work (A4), so path confinement would add
failure modes without adding safety. What is enforced is
operational — timeout with process-group kill so a backgrounded
server cannot outlive its command, output truncation so a build log
cannot eat the context window, closed stdin so prompts fail fast,
and a scrubbed environment so project code never sees the agent's
credentials
- executor.py: renders the log, calls the executor role with the
reduced manifest, dispatches tool calls, and checkpoints every
result (A13). Escalation is observable, never self-assessed (A12):
consecutive command failures and a hard turn cap. Failed reads do
not count — looking around is not failing
- Until delegation exists (step 7), escalation gates to the human
with the failures attached
- control.py: start milestone now runs the executor; a stopped
milestone gates to AWAIT_INPUT
Two bugs found by running it live against real models:
- A turn-ending gate tool (submit_plan) left a tool_call with no
tool_result, which providers reject outright, so every planning
turn after the first failed with a 400. The planner now records the
gate as the call's outcome, which is also the truthful transcript
- The renderer now emits a placeholder for any tool_call lacking a
result, so a crash between call and result (A3) cannot poison the
transcript permanently
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- planner.py: LLMPlanningAgent offers exactly two tools, submit_plan
and ask_human, so gating stays structural at the tool layer; no
proceed tool exists. System prompt requires explicit, low-inference
plans because a weaker model executes them (A13)
- Model output is appended to the canonical log as it arrives, before
being interpreted, so a crash mid-planning leaves a truthful
transcript
- A bad turn (prose instead of a tool call, malformed arguments, or a
failed call) retries once, then raises PlanningError; the
orchestrator gates to AWAIT_INPUT rather than inventing a plan
- Orchestrator: appends every operator message, plan_submitted, and
gate_opened to the transcript, so re-planning sees rejection
feedback as conversation rather than out-of-band parameters; pins
the A11 role mapping at task start
- llm.py: non-JSON or non-object tool arguments now raise LLMError
instead of escaping as JSONDecodeError — observed from llama3.2,
which emits truncated argument strings
- Live planning test is opt-in on NISSE_LIVE_PLANNER_MODEL: planning
escalates unconditionally (A12) and the local model does not hold
the tool-call contract, so it awaits a frontier route
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step-2 PR A: the approval loop end to end with a stub agent, no LLM
and no network.
- transport.py: Transport interface per A1 (send_message,
send_approval_request, ask_question, on_message) with an
InMemoryTransport; no platform SDK outside an implementation
- gates.py: PlanRevision with monotonic rev-N tags; Gate.evaluate
enforces revision binding (stale tags refused with a notice),
operator allowlisting, and approve-word-only semantics; any other
reply is rejection feedback. Channel seam ready for Gitea/Vikunja
approval sources
- control.py: B1 state machine subset with structural gating — the
agent's only actions are SubmitPlan (ends its turn) and AskHuman;
no proceed tool exists. Gate state, revision counters, task_id,
and pending questions checkpoint through StateStore, and boot
resumes an open gate or gates to AWAIT_INPUT, never blind-resumes
- gate.open/approve/reject A9 events complete the log taxonomy
- CLAUDE.md: record the plan-first working convention
- Tests: 15 unit (gates + orchestrator flows), 2 integration
(mid-gate restart with revisions intact; unresolved intents gate
the whole boot)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>