A model works out and sets up what a project needs on its build VM;
code makes sure that happens, bounds it and checks the result (D3).
- Before each driver run on a project, one round trip checks the build
VM: the harness's own baseline (missing: the item waits, a template
problem), the workspace clone (missing: cloned again and said; the
item resumes from what it pushed) and a per-project marker.
- No marker (a new project, a new or wiped VM): the item's driver model
runs a bounded preflight first, with its own prompt, the execution
tools (screened as ever) and toolchain_ready. The harness runs each
reported version command itself (version-shaped commands only, even
from the marker, which the model's commands could edit) and writes
the marker. Gaps and an unfinished preflight are facts and work goes
on; the thread hears only of installs and gaps. A rebuilt VM starts
from the last toolchain.
- Later runs check the versions again; a change is a fact and a thread
line. The PR body says what the work was tested with.
- A changed host key is its own failure (HostKeyChanged): the item
waits for the operator's `trust new host key`, which drops the pin;
then the VM is set up again.
- A prompt line: install what the work needs on the build VM, say why,
and put lasting needs in the project's own pins.
- tuning.preflight: max_turns, command_timeout_s.
- The property walk gains a VM wipe and ready_before_work (checked
against a mutant that skips preflight); parked_work_safe accepts that
a wipe loses what wasn't pushed (D3).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
The local driver sometimes ran `git commit` itself instead of calling
finish_milestone, then told the operator a PR was waiting when none had
been opened. A commit still runs (it's contained, and publishing takes
whatever is there), but when the driver's command commits anywhere in
its line, the harness adds to its result: committed locally only;
nothing is published until finish_milestone, and there is no PR until
then. Structural, at the moment the model would draw the wrong
conclusion (P6), rather than left to it noticing.
Closes#129
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A project's trust.merge is operator (the default) or approved (L3, D5,
#65). Set to approved, the harness, never the model, merges a PR in the
PR watch once an approving review from a gitea.operators account is on
its current commit, its checks pass (none at all counts; pending
waits) and it's mergeable; the item then goes on as after any merge.
Each reason to hold off (failed checks, conflicts, a refused merge) is
said once per commit.
Gitea enforces it: protection requires approvals from an operators-only
whitelist and dismisses stale ones. At start, for each approved
project, the agent checks by behaviour that its own unapproved merge is
refused, with a throwaway branch and PR made through the API (never the
workspace); if it isn't refused, the operator merges until a restart.
- gitea: approvals, combined check status, a PR's head commit and
mergeability, API-made branches and their deletion.
- Test and bring-up repositories get the protection.
- Tests: the decision (unit), the hard wall on a real Gitea
(test_trust_live), an e2e test where the operator only approves, and
a walk property, merges_only_approved, checked against a mutant that
ignores which commit was approved.
Closes#65
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
What the fast path calls unknown is judged by an independent model call
on the item's classifier role (L4), before it runs.
- nisse_dev.loop.classifier: it sees the action and the agreed scope
(project, repository, issue number, the restatement the operator said
go to), built in code; never the transcript, the issue's text, tool
output or the driver's reasoning. Its verdict is a tool call: safe
runs; in_scope runs and is reported in the thread; needs_discussion
asks, as a dangerous action does. An error, no verdict or an unknown
one fails closed. Verdicts are cached per item, agreed revision and
action. A helper is refused what needs discussion.
- action_verdict gains source, model and the classifier's exact input;
details shows the source. An allow after the classifier asked is an
override. scripts/classifier-evals.py exports the judgements from a
store, read-only; tests/integration/classifier_cases.yaml is the seed
set, run per model with --capability-models.
- The fast path's delete rule: deleting on the build VM is contained;
only what breaks the host (system and home directories) stays
dangerous, since in tests the build VM is the test machine.
- The classifier role is the local model in the example config.
- The property walk gains a classifier answering at random, junk and
failures included; dangerous_only_when_allowed now covers its
verdicts (checked against a mutant that runs them all).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Every execution tool call, the driver's and a helper's, gets a verdict
from code before it runs (P3, P4, D6).
- nisse_core.actions, nisse-core's first code: Action and Verdict, and
reading shell commands statically, segment by segment (cd, wrappers,
sh -c). What can't be read that way is unknown; a line is as bad as
its worst command.
- nisse_dev.loop.actions, the rule table: reading, checks and tests are
harmless; workspace edits, local git and installs on the build VM are
contained; pushing, other hosts, uploads, publishing, the forge's own
tools, deleting outside the workspace and wrecking the VM are
dangerous; changing branches by hand is refused (the harness owns
branches) with a pointer to the milestone tools.
- A dangerous call isn't run. The item waits on the operator with a
question rendered from the action, and `allow` lets exactly that
action run once. A helper can't ask, so it is refused and told to
hand back. Unknown actions run, logged, until the classifier (M3.2).
- Every verdict is an action_verdict event, which no model sees, and an
oplog line; `details` shows the counts and every action not simply
run.
- The property walk gains dangerous_only_when_allowed (checked against
a mutant that doesn't use the allow up); the slice e2e checks every
call was judged and none needed an allow.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
nisse-dev now runs only the conversational loop. The gated state machine
it was built alongside is gone:
- Removed control.py, planner.py, executor.py, approvals.py, gates.py
(command_form moves to loop/reader.py), publish.py and poller.py, the
old assembly in agent.py (its shared helpers move to loop/build.py),
the store's state-machine view (get_state, transition), their tests,
the old end-to-end and live planning/milestone/gate tests, and
specs/orchestrator.qnt with its scenarios.
- Roles are driver, helper, expert and classifier; driver is required.
loop:, roles.planner, roles.executor and tuning.executor are refused
with what to use instead; tuning.driver takes the turn cap and failure
limit.
- The benchmark and the capability matrix measure the driver: the
loop's own system prompt and tools, the task's plan as the agreed
restatement. Task plans end with finish_milestone, so earlier runs
show as (old); stored runs' executor tuning reads as the driver's.
- A delegation on the loop records what its helper's alias resolved to
(L9), as the old path's did.
- The dump shows a work item's revision and what it waits for.
- Behaviour the old tests covered and the loop shares stays covered:
driver runs that fail on a model call, stop mid-turn, reset the
failure count; go-aheads ignoring case and punctuation; a request too
big to fit is never sent; the assembly tests, now on build_loop_agent.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- A near miss for abandon ("abort", "cancel", "drop it", ...) in an item's
thread is asked about, and changes nothing.
- The driver is told only the operator stops or closes an item, so it
doesn't claim to have done either.
- An issue assigned again after its item closed starts a new item that
notes the one before (Gitea's timeline gives the assignment time);
item_for_issue returns the latest item.
- Abandoning an issue Gitea won't close because of open dependencies says
so, naming them.
- An agreed item isn't offered restate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Found live on 2026-10-07 (bring-up on nisse-testing-operator/nisse-bringup,
Opus as driver):
- The driver's prose nudge went into the system prompt, so the retry ended
on the model's own reply. Opus 5.5 refuses that ("does not support
assistant message prefill. The conversation must end with a user
message"). The nudge is now a harness fact in the conversation. The
delegation harness's prose path did the same and is fixed likewise.
- A helper's first request ended with its own delegation_started event,
rendered as an assistant message: every delegation would have failed on
Opus. The helper now sees it as the harness handing over its task.
- model_contract checks that every request ends on the user's side, so
scripted tests catch this from now on (each fix is mutation-checked).
- `status` (and `details`) in an item's thread were taken as replies: they
woke the driver and cancelled a bug's countdown. They now answer at once
and change nothing.
- The driver had no way to answer a remark without changing the item, so it
answered in prose. Every run woken by a message (or a review comment) now
offers `reply`, which answers where asked and returns to the very wait
that was interrupted.
Also: scripts/bringup-repo.sh makes the bring-up's test repository
(make_repo/delete_repo, split out of fresh_repo), and made repositories get
the agent's labels (bug, discuss, design).
Tests: test_loop_live_fixes.py (one per finding); the walk sends `status`
into threads (status_changes_nothing) and its scripted driver sometimes
answers remarks with reply.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Questions never change state (L5, #77): a message ending in "?" to a
waiting project item starts a question run (reply and look-only tools,
an instruction to change nothing), and the item goes back to the very
wait it was in: a restatement up stays up and a go binds to it; the
agent's own question stays open.
- Explanations: a restatement gives its reasons and the main alternative;
a revision says what changed (restate(changed), sent back without it);
a milestone's summary says what was done, verified and left open.
- Progress in prose (#42): the driver's and a helper's own words for a
turn are posted, trimmed, at most once a minute per item; `details`
shows the last tool calls; a message arriving mid-run is acknowledged at
once ("Noted; ...").
- CI: the e2e job's comments and the plan no longer claim a newer push
cancels the run it supersedes (Gitea here doesn't); the concurrency
block goes.
Tests: conversation unit tests (questions in a go-ahead and an answer wait,
an unanswerable question, revisions, progress rate and trimming, details,
mid-run notes), the helper's progress, the walk asking questions in every
state with a questions_change_nothing property (routing questions the old
way fails 32 of 48 seeds), and an e2e question while a restatement is up.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- roles.driver (unset: the planner's model) and roles.classifier (unset:
the expert's); the mapping is pinned when an item opens, and the driver
and the go-ahead reader run on the item's own (L9).
- profiles: in configuration (tools, strengths, weaknesses), used in
briefings: the driver's instructions say what its helper and expert are
good at, the helper's brief who reads its handback; never a model name.
- delegate(task) and ask_expert(question) for the driver. A helper works
on a branch off the milestone's; on a handback its work comes back as
uncommitted changes, otherwise it is discarded, so harness branches
never hold commits only the clone has. ask_expert keeps its budget.
- Triggers: failure_limit failed commands in a row (Driver's new "stuck"
outcome) or the turn cap, mid-milestone, call a helper in once per
milestone; if it fails too, the item waits on the operator. A
delegation cut off by a crash is discarded at boot.
- Evaluation capture: delegations and ask_expert as before, and the
reader's verdicts as classifier pairs.
Tests: help tests (merge-back, discard, needs a milestone, the stuck
trigger and its once-per-milestone limit, the expert and its budget,
pinning, briefings by profile, boot discarding a cut-off delegation),
config tests for roles and profiles, and the walk delegating with a
scripted helper that sometimes fails, with a delegations_settle property.
The walk found helper commits stranded on a merged PR's branch, fixed by
returning a helper's work uncommitted.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Found by the first local e2e run: qwen3.8-27b put a new project's code in
the same PR as its ARCHITECTURE.md, with no build plan. D1 says a `design`
item agrees its design before any code, and that rested on wording only.
- Until a PR of a `design` item has merged, finish_milestone is sent back
if the milestone changes anything but ARCHITECTURE.md and docs/, naming
the paths to take out. The merge is recorded (design_merged) and code
may follow.
- The label's instruction and the new project's brief say so plainly:
documents first, in a PR of their own; code after it has merged.
Tests: a design milestone with code is sent back and only the documents
are published, then code after the merge; the walk's model follows the
rule.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- propose_project(name, purpose, decisions) in a conversation goes up for
a go-ahead (checked: a slug, not an existing project, no trailing
question).
- On go, the provisioning checklist (D1): a repository with a README,
protection on its default branch, the agent as a collaborator, a build
VM, a deploy key, and the project entry as YAML with <...> to fill in.
- On "done", the agent reads its configuration again and adds the new
project live (build_loop_agent(load=...); only additions), then checks
what was provided (D4, verified, not assumed): permissions and
protection as it sees them, then on a throwaway branch with an empty
commit, a branch push must succeed and a push to the default branch and
merging its own PR must be refused. The check's PR is closed and its
branch deleted. Failures are said per item; nothing starts.
- Checked: the tracking issue (the distilled decisions, carrying the new
item's ID), and the project's item, agreed and labelled `design`, whose
first milestone is ARCHITECTURE.md and the build plan. A conversation
waiting on provisioning doesn't close as idle.
- The walk replays exactly from its seed (item IDs too), and event IDs
stay unique across simulated restarts, as a server's are.
- The test config holds output to 8192 tokens, under the test key's
per-request cost cap.
Tests: new-project unit tests (checklist and its YAML parse as config, done
before the entry, what isn't set up, a checked project's record and first
PR, an existing name), protection enforced by a hook on the fake forge,
the walk proposing projects and saying done, and an e2e new project in a
repository the test operator provisions from the agent's checklist.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- A top-level message that isn't a command is a conversation: an item in
the `general` scope (L1), threaded under the operator's message, with
`reply`, `ask_human` and `propose_work` and no workspace or execution.
`general` is reserved as a project name.
- propose_work(project, title, summary) goes up as a revision for a
go-ahead (or a plain yes). On go, under a file_issue intent (L8), the
record issue is opened in that project, assigned to the agent and
carrying the new item's ID; its item starts agreed, with the summary as
its brief. Intake skips any issue carrying an ID (D1); boot adopts a
filed issue or puts the proposal up again.
- A conversation closes after tuning.loop.general_idle_s (default a day);
a reply in its thread then starts a new conversation that continues it.
Tests: conversation unit tests (threading, filing, the marker, an unknown
project, changes, idle close and continuation, a plain yes, both crash
cases, commands at top level); the walk with conversations, proposals and
idle time, and records_have_one_item / conversations_do_no_work; an e2e
conversation that becomes filed work.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
build_host, push credential, labels (defaults added to every issue),
secrets by ${VAR} reference with visibility (commands | harness), and
egress (recorded, not enforced). Every problem in every entry is
reported at once; an inline secret, a bad visibility, a repeated repo or
a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
commands-visible secrets, redacted on return), push credential and
milestones; intake polls every project's repository; items are scoped
to their project for every run, publish and PR watch. Issues are named
project#14 when there is more than one project, and a bare #14 that
matches two is asked about. Switching projects parks the one worked on
last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
posted: the e2e driver kept asking "Is this the right interpretation?",
which a plain yes can't answer.
Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Every milestone branch is cut from the latest default branch: the next
milestone builds on the merged previous one, and a redo after a closed PR
drops the rejected work. Uncommitted work comes along.
- While the item's PR is open, start_milestone is refused, with the PR's
state read from Gitea at that moment, not the last watch round.
- finish_milestone(final): earlier PRs say "Part of #N", the last "Closes
#N"; unsaid, it's final, as before.
- close_item with the item's PR still open waits for the merge (L2: a
closed item leaves nothing open).
- The driver's instructions cover ARCHITECTURE.md and build plans under
docs/plans/ (D2); the harness never parses a plan.
- Parking stashes uncommitted work and pushes it to <item>-parked, put back
on resume, instead of committing it onto the item's branch. The walk
found a half-done review fix parked onto a PR that had merged meanwhile,
where it was lost. Architecture D4's parking line says so.
Tests: unit tests for fresh milestone branches, the merge wait (including
a merge not yet watched), redo after a closed PR, Part of/Closes, close
waiting on an open PR, and the parked-fix regression; the walk's new
milestones_start_fresh property (fork-from-current fails 9 seeds); an e2e
two-milestone feature.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Commands that name their item ("go #14", "abandon #14", "stop #14")
apply to it wherever they're said; go, approve and abandon alone at top
level are asked about (L5). stop is never taken as an answer.
- A plain-language reply to a restatement is read by GoAheadReader, one
model call that agrees only on a clear yes; questions never reach it. The
agent says "taking that as a go-ahead on rev-N" first, bound to the
revision on screen at arrival (L3, L5, D1).
- Labels steer (D1): `bug` goes ahead after tuning.loop.bug_grace_s
(default 600 s) unless the operator replies; `discuss` isn't offered
restate until the operator has spoken; `bug`, `discuss` and `design` add
to the driver's instructions. A restatement asks no questions.
Tests: label and command unit tests, the property walk with labelled
issues, plain yeses, named gos and time passing (grace_only_unanswered,
discuss_talks_first), and e2e tests for a bug proceeding after its grace
period and a plain-language go-ahead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Several work items on the new loop (L1, D4):
- Switching parks the item on the workspace: its work is committed and its
branch pushed; resuming checks the item's branch back out. Branches of
open items are never set aside by a fresh workspace.
- Next item: the operator's "do #N next" first, otherwise the oldest.
- Issues with open Gitea dependencies are blocked until they close, checked
at intake and on every round; status shows blocked items and the line.
Tests: queue unit tests, the property walk over several issues with
dependencies and a new parked_work_safe property (it catches parking
disabled), and an end-to-end test with two issues where B depends on A.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
From the new loop's first live run (sandbox #255, PR #256):
- A question asked on the PR is answered on the PR (an inline one quoted
with its file and line, since the API can't reply in its thread), with
a pointer in the item's Matrix thread; one asked in Matrix stays there.
- wait_for_review: when a review needs no change, or only the operator's
merge is left, the item waits on the merge instead of on an answer.
- finish_milestone takes the PR's title from the driver.
The e2e test's merge waits out Gitea's brief 'try again later' after a
push.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
The first live run of the new loop's e2e test found that a fact recorded
while start_milestone was in flight landed between the tool call and its
result; both API formats reject that, so every later request in the run
failed. Views now keep each result right after its call. Scripted test
models check the APIs' ordering rules, so this can't pass unit tests
again.
The e2e tests no longer depend on the sandbox's history: each run asks for
a uniquely named function and works against a base branch of its own,
deleted afterwards. The review step asks for a change that can't already
be there. The scripted operator gives up after five fruitless answers, and
each run keeps its state store for inspection. The driver's prompt says
review fixes stay in the current milestone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
test_loop_properties.py walks the new loop with a seeded mix of operator
and Gitea actions (go-aheads, stale go-aheads, answers, comments, merges,
the comment-then-merge race, closes, abandon, a crash and restart) and an
adaptive scripted model, checking every loop.qnt property after every
step. It found two bugs, both fixed with regression tests: abandoning an
item left its open PR open (the model's fix 1, now in code), and after a
review fix became a follow-up PR, the next fix went to the merged branch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A work item starts from a fresh workspace: fetch, set aside (never
discard) leftover changes, branches and unpushed commits, a clean default
branch, and a branch of its own (D3, #62). start_milestone cuts the
milestone's branch; finish_milestone commits only the work, pushes and
opens a PR that closes the issue, under an intent, adopting an open PR and
treating a closed one as the operator's answer (D4). Between runs the
loop watches each item's PR: operator comments and inline review notes
(#69) reach the item, a merge or close hands back to the driver. A review
fix is checked after it's pushed and goes out as a new PR if the PR merged
first (D5). At boot an interrupted publish is settled against Gitea (L8).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
nisse_dev.loop runs alongside control.py, chosen with `loop: new`.
Issues assigned to the agent (by an operator, or filed by one) become work
items, each with a Matrix thread. A driver run is one model with tools
until it calls a waiting tool: restate puts rev-N up for a go-ahead, bound
to the revision on screen when the reply arrived; ask_human waits for an
answer; close_item closes the item and its issue. Before the go-ahead the
driver can look but not change or close anything. One item is active at a
time; replies in an item's thread go to it. On boot, an item cut off
mid-run waits on the operator, and waiting points are put up again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE