The CI wait made a head with no checks wait ci_grace_s (2 min), so the
live test's single round reviewed nothing. Its fresh repository has no
CI: the test sets the grace to 0.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
scripts/e2e.sh ran only test_end_to_end_loop.py, and the integration step
excludes e2e-marked tests, so test_review_live.py (M7.3) never ran in CI.
The script now runs every e2e-marked test under tests/integration, and the
review test is a smoke test: about a minute on the local model, and the only
check that the review account, its token and its read-only refusal work.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
A new package over nisse-core: it reviews every ready PR in the
repositories its account can read, and posts one COMMENT review.
- scheduler: each round, open PRs per readable repository (or the
configured repositories); an item per PR, ready unless draft or WIP;
closed with the PR. One review at a time, oldest first.
- reader: the repository at head and base from Gitea archives, unpacked
and read in Python (read_file, list_files, search_code), and its issues;
bound to the item's repository; no git, no shell.
- review: starts from the PR, commits, diff, comments, closed issues, CI;
comment drafts a point on any existing line; post_review posts one
COMMENT review at the head, inline where Gitea takes it, else as links in
the summary. ask_expert; vault and memory (memory/nisse-review).
- L8: posting is an intent; recovery asks Gitea for the marked review and
never posts twice; with Gitea down, the item waits.
- Failures retried, then said on the PR until the next push.
- Startup refuses an account that can push to or administer any repo.
- nisse-core gitea: readable_repositories, open_pull_requests, diff,
commits, archive, issue, search_issues, reviews, post_review; PR fields.
- Bring-up: scripts/review-bringup.sh; nisse-review.example.yaml.
- Tests: unit over a fake Gitea and scripted model; a live test
(NISSE_REVIEW_GITEA_TOKEN) reviews a PR in a fresh test repository.
- Docs: Development Plan M7.3, Tuning Guide (tuning.review.*).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
Shared parts, not a shared scheduler (agreed): nisse-review will have its
own small scheduler; a common one waits until two exist to compare.
- nisse_core.loop.driver: the driver run, moved; the agent names its
waiting tools per run (nisse-dev's are WAITING_TOOLS), and execution
goes to any Executor (ToolOutcome moves with it).
- nisse_core.loop.tools: the tool helper and the tools any agent offers
(notes, memory, memory_done, ask_expert); nisse_core.loop.expert.
- nisse_core.knowledge.handlers: Knowledge, the vault and memory as
control tools, the index and prompt text, out of LoopAgent.
- nisse_core.config: file reading, references, people, tuning sections of
the agent's own, LLM numbers, the vault.
- New core tests: a driver run with a non-workspace executor and the
agent's own waiting set; Knowledge alone; the shared config pieces.
Docs: the Development Plan marks M7.2 done; Architecture Part L and the
nisse-core README name the new parts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
What isn't the dev agent's moves to nisse-core, unchanged in behaviour,
with its tests: canonical, render, render_anthropic, llm, catalog, probe,
context, views, state, reconcile, worker, cancel, dump, transport, matrix,
execution, testing, vault, memory, oplog, envfile and gitea (whole).
- nisse_core.state.StateStore holds the tables every agent has; an
agent's subclass adds its tables (version, migrations), roles and
views. nisse_dev.state.StateStore adds the build VM blocks (v5).
- views.project takes what each role is shown, rather than a fixed table.
- Vault takes the agent's name (agent=), for provenance.
- A test that nisse_core never imports an agent.
- The loop, config, git, delegation, expert, tools and evals stay in
nisse-dev until nisse-ops has a loop to share.
Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on
the agent host and Terraform; Architecture Part L names the package.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
claude-haiku-4-5 is being replaced by claude-haiku-5-5 in LiteLLM: the
integration tests' roles and the example config's expert move to it.
Its profile keeps the predecessor's benchmark figures, marked as such
until it's measured. The plan's models-by-role line also gains the
local classifier (M3.2). Results measured on 4.5 stay as they were.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
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
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
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
The e2e roles move to qwen3.8-27b-nothink, a LiteLLM alias for the same
model with chat_template_kwargs enable_thinking false. With thinking, a
growing share of answers ran to 2-8k tokens (two hit the 8192 cap), which
took one test from 3.5 to 20 minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Every PR: lint, unit tests across the runner's cores (pytest-xdist), 16
property walks from a seed that moves with the run number, integration
tests, and the end-to-end smoke tests (the slice, a conversation filed
as work, a question; `smoke` marker).
- A PR labelled e2e:full, a push to main, or a manual run: all 48 walks and
the whole end-to-end suite. Adding e2e:full to a PR runs it; any other
label runs nothing.
- A published release runs the full set before its image is built.
- The walk's seeds come from NISSE_WALK_SEEDS / NISSE_WALK_FIRST (48 from 0
by default).
Measured here: the unit tier on 2 workers took 3m49s against 6-8 minutes
serially.
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
- 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
- Every e2e role runs on qwen3.8-27b on the RTX 4090 (e2e_roles();
NISSE_E2E_MODEL overrides, e.g. claude-haiku-4-5). The first full run
passed 7 of 8 in 26 minutes for $0.0000; the eighth was a real finding,
fixed with the new-project PR.
- scripts/e2e.sh reports a run's spend on the test key and the day's total
against its budget: above $0 on the local model means a paid fallback.
- CI runs the suite as its own job on pull requests (not pushes to main),
on a throwaway sshd (--local), cancelling a run a newer push supersedes,
so the checks job still reports in minutes.
- The plan says so: e2e on every PR; locally only -k / --lf.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Every live test that touches a repository gets one of its own from
e2e_support.fresh_repo, made by the test operator as an operator makes a
project's (D4, D8): seeded from tests/integration/seed/ (the sandbox's
package, without past runs' leftovers), the agent's account a collaborator
with write access, main protected (pushes only by the owner, an approval to
merge), and a deploy key generated for the run. It is deleted afterwards
(NISSE_E2E_KEEP_REPOS keeps it); scripts/e2e-cleanup.sh deletes leftovers.
- Both e2e fixtures use it; per-run base branches are gone. The merge
helper approves first, as the protection now requires.
- test_gitea_live uses it, so CI needs NISSE_OPERATOR_TOKEN and no longer
writes the sandbox deploy key (NISSE_PUSH_KEY).
- A live test of the helper: seeded, shared with the agent, protected
against it, deleted.
- nisse-test.yaml's project entry is a placeholder; pytest and ruff skip
the seed.
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
From an issue the scripted operator files and assigns in Gitea, through
the restatement, go-ahead, milestone and PR, a review comment answered
with a pushed fix, to an approved and merged PR and a closed item and
issue. The operator acts in Gitea through its own test account
(NISSE_OPERATOR_TOKEN) and in the item's thread through the in-memory
transport. Runs from scripts/e2e.sh.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Transport.post(text, thread=...) returns the event ID, which can root a
thread; a threaded post carries m.thread with the reply fallback for
clients without threads. Inbound messages carry their event ID and thread
root: a message in a thread gives its root, a plain reply joins the
thread of the message it replies to when known, and the quoted lines of a
rich reply are no longer part of the text. The in-memory transport plays
threads for tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
tests/integration/test_end_to_end.py drives the whole assembled agent
(real models, the Gitea sandbox, a real build host) through a milestone
with a scripted operator, and checks what bring-up run 3 checked by hand:
a PR holding only the work, pushed with the push credential; no
credential in the clone; the planner's view carrying facts, not the
executor's turns. It cleans up after itself on the sandbox and the build
host.
scripts/e2e.sh runs it on the bring-up build VM (or --local). It has its
own marker, e2e, kept out of check.sh and CI since it spends tokens.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
The push credential is either a push-only account's token, handed to git
through a credential helper that names environment variables, never
values, or a deploy key in a short-lived ssh-agent. The remote URL
follows the credential. Configured as gitea.push_user and push_token, or
gitea.push_key.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
execution.SSHHost.execute is now the only way the agent runs anything
(L13). Commands travel as a script on SSH's stdin, so the secrets handed
to a call are never on a command line, and every value handed out is
redacted from the output. Timeouts kill the remote process group, and
an unreachable host is reported as such, not as a failed command.
Workspace and GitRepo run on the build VM. The clone uses the
repository's SSH URL, so no credential is stored in it (#89); fetches
and pushes get the push key over stdin, in a short-lived ssh-agent,
and PRs are still opened from the agent's host with its API token.
The bench runs on the build host too.
Tests reach "the build VM" through a throwaway local sshd (nisse_dev
.testing); CI installs openssh-server. The live push test now pushes
with the sandbox's deploy key (NISSE_PUSH_KEY).
Fixes#89.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Development Plan M1.1, as discussed.
- Schema v4: a work_items table (id, scope, title, status, outcome,
position, data) and an agent row whose active_item points at one
(NULL: idle). Canonical events and intents carry their work item, and
events their actor. Older stores are refused with a clear message:
there was no production data, so no migration (the mechanism stays).
- The log is reached through a Transcript bound to one work item and
one actor: store.transcript(item, actor) reads only that item and
writes with both. The planner, executor, delegation and expert desk
get one per call from the orchestrator instead of holding the store,
so a component can neither read another task's events (the planner
read every earlier task's, #78) nor write without attribution.
store.all_events() is kept for readers that span items (evaluation
capture, dumps). Intents are begun through the transcript or with
work_item=, so each belongs to its item.
- The orchestrator opens a work item on 'use', closes it with its
outcome (completed or abandoned), and carries what the operator said
while idle into the new item as copies with their origin, so a task
sent before 'use' is still planned from (D1). control.py keeps its
state machine through get_state()/transition(), now over the active
work item, until M2.6 retires it.
- nisse_dev.dump shows work items and each event's item and actor.
Tests: test_work_items.py (scoping, attribution, intent ownership,
restart, outcomes, carried messages, a second task never seeing the
first); the suites updated for the per-call transcript.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Architecture L9's role names, for the two roles with a direct
counterpart: the delegation role is now 'helper' and the ask_expert role
'expert', in RoleMap, configuration (roles.helper, roles.expert),
recorded events and tests. 'planner' and 'executor' stay distinct for
now; they become 'driver' when the new loop merges them (Development
Plan M2.3).
Configurations must rename their keys: nisse-dev.example.yaml and
tests/integration/nisse-test.yaml are updated here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
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
The test checks that a request reaches the executor model through
LiteLLM and text comes back. It also asserted the model obeyed "Reply
with exactly one word: pong", which Haiku once answered with "ping" and
failed CI on #66. Assert non-empty text instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
It drove the orchestrator until EXECUTING and accepted EXECUTING as the
end state, which with no publisher configured was the stranding this
branch removes: the milestone now finishes in PR_OPEN. Return once
'start milestone 1' is sent, since the milestone runs inside it, and
expect PR_OPEN or AWAIT_INPUT.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Choosing a local executor model, and tuning its knobs, needs measured
success rates across repeated runs, comparable with every model
measured before.
- Four canned milestones under nisse_dev/bench/tasks: write a function,
fix a bug a failing test exposes, thread an option through several
files, and find one failure in output far past max_output_chars. Each
is an approved plan over a seed project, judged by a hidden check the
model never sees; a reference solution proves each is solvable and
that its check tells the difference
- Runs use the agent's own client wiring, now shared as
make_llm_clients, with delegation and ask_expert off: an escalation is
a failure of the model under test
- Each run is stored as one JSON line in bench/results/, committed, with
the model's backing name and declared facts, a note on how it was
served, the task's content hash, the agent's commit and tuning, the
outcome, turns, misbehaviour counts, context trims, tokens, time and
LiteLLM's cost; a fallback answer can never count as a success
- The report rebuilds the comparison from every stored run, with Wilson
95% intervals, marking runs against older task versions
- scripts/benchmark.py runs and reports; scripts/evaluate_model.sh takes
a new model through the context probe, the capability matrix and the
benchmark in one command
- Clients keep LiteLLM's per-call cost, and BudgetedClient counts the
requests it had to trim
- The tuning guide gains "Benchmarking models"
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The executor ended a milestone on any reply without a tool call, and
nothing held a model to the offered tools. Measured on the 4070:
tool_choice "required" is enforced by vLLM (local-fast) and Anthropic,
but qwen3-14b (local-coder) under it cannot stop and writes until the
output ceiling, a known model bug. So enforcement is asked for per
model, and the loop holds the contract itself everywhere.
- The catalog reads supports_tool_choice from LiteLLM's model_info;
BudgetedClient sends tool_choice only to models declared true, as
"required" on the normalized route and {"type": "any"} on the
Anthropic passthrough, and only when tools are offered
- The executor gains an explicit finish tool, the only way to end a
milestone. Finishing with nothing changed is refused once; a prose
reply gets one nudge, then escalates; a call to a tool not offered
counts toward failure_limit, like a failed command; calls after
finish are recorded as not run
- Clients record the model that actually answered. ModelFacts.answered
recognises a proxy fallback, which once credited Claude's answers to
a local model that could not call tools at all
- The context probe reports a fallback instead of trusting it; rerun,
both local models now refuse over-long prompts, as they should
- The capability matrix checks every answer came from the model asked,
that models call only offered tools, and that a declared
supports_tool_choice is true
- Spec A15 and the tuning guide say how enforcement is requested,
declared and backed up
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every turn re-sent the whole transcript with nothing checking it against
the model's window. On a local model that fails silently: Ollama drops
the start of an over-long prompt, which is where the system prompt and
the plan are. Measured on the Jetson's llama3.2, cut at 4096 tokens with
no error.
- The model catalog reads max_input_tokens and max_output_tokens from
LiteLLM's model_info, and startup refuses a role on a model with no
declared window
- BudgetedClient wraps both routes. It fits the canonical events before
rendering: older tool results cut to head and tail, then the oldest
turns dropped with a note, keeping calls with their results; a request
that still cannot fit fails with ContextOverflow and is never sent.
The output ceiling is the smaller of config and the model's limit
- Tokens are estimated from characters, pessimistically, and the
estimate is corrected from reported prompt sizes, only ever upward
- The executor takes its scope from the log: the latest plan, which is
the approved milestone plan, goes in its system prompt, and it sees
only events after it
- Tool output keeps its head and, mostly, its tail, so a test runner's
failure summary survives truncation
- tuning.context.keep_recent_turns and safety_margin are new knobs
- scripts/probe_context.py checks a declared window against what the
server does; it confirmed llama3.2 cuts at the declared 4096
- The tuning guide covers the budget, declaring a local model's limits,
and the probe
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NISSE_CAPABILITY_MODELS was neither a secret nor configuration: which
models to measure is a question for one run, so it is now an argument,
--capability-models=a,b, declared in tests/conftest.py where pytest
reads options at startup. With no models the matrix skips as before, so
CI is unchanged. The last non-secret NISSE_* variable other than
NISSE_CONFIG and NISSE_ENV is gone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With no production instance yet, there is no reason to carry two ways
of configuring the agent. Every environment variable that duplicated a
config key is gone; the environment now supplies only the secrets the
file references by ${…}, plus NISSE_CONFIG and NISSE_ENV to find the
files.
- AgentConfig loads from a file (load, from_file) or a parsed tree
(from_tree, for tests); a missing file is an error naming the example
- The from_env constructors on LiteLLMClient, AnthropicPassthroughClient,
GiteaClient, MatrixConfig and RoleMap are removed, with the env
reading for max output tokens; clients take their settings from config
- The live integration tests share a committed agent config,
tests/integration/nisse-test.yaml, aimed at test resources, and one
conftest that loads it and builds clients. NISSE_LIVE_PLANNER_MODEL
becomes its planner role; NISSE_CAPABILITY_MODELS stays as a per-run
switch
- A missing secret now fails the live tests instead of skipping them
- CI passes only the three tokens
- .env.example is secrets only; spec A5 says config comes from the file
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Route selection sniffed model names: anything starting "claude-" or
"anthropic/" took the vendor-native passthrough, everything else took the
normalized route. That works only while an alias names its vendor. A
semantic role alias like "remote-architecture" is meant to point at
Anthropic today and somewhere else tomorrow, and the name says nothing
either way -- so it would have taken the normalized route regardless of
what was behind it, silently losing the fidelity A13 wants for delegations.
LiteLLM already knows the answer. /model/info reports each alias's backing
model, provider, and declared tool support, so the agent asks at startup and
routes on the provider it discovers. That keeps vendor knowledge where A5
already put it -- in the proxy -- rather than duplicating it as name patterns
here.
There is deliberately no name-based fallback: PASSTHROUGH_PREFIXES and
has_native_adapter are gone, and select_client now requires a catalog. A
second heuristic could only ever disagree with the proxy. An unknown model
raises instead of guessing a route.
Misconfiguration is therefore loud and early. build_agent validates every
role against the catalog before it clones anything, naming all the bad ones
at once. Discovery is per key -- /model/info returns only what the presented
token may use -- so the same check catches a role pointed at a real model
this key cannot reach. An unreachable proxy is reported separately, since an
outage and a typo call for different responses from the operator.
Two things now get recorded rather than inferred: the resolved model is
pinned alongside the role mapping at task start, for the reason A11 already
pins roles, and DelegationStarted carries backing_model so A16 eval history
is not averaged across whatever an alias happened to point at.
A model LiteLLM declares tool-less warns at startup rather than refusing;
A15 expects weak local models to be accommodated, and they are observed
driving tool loops anyway.
Verified live: all four aliases the production key can see route correctly
through discovery, and each chosen route works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The arrival of non-Anthropic models made a latent bug observable: the
delegate's client was hardcoded to the Anthropic passthrough, so
pointing NISSE_ROLE_DELEGATE at gpt-6-astra or a local model would
have sent Anthropic-shaped /v1/messages bodies to something that does
not speak them. A5 promises the frontier tier is swappable "as a
configuration change, not a refactor", and it was not.
select_client now picks the route from the model: the vendor-native
adapter where one exists, LiteLLM's normalization otherwise — which is
exactly what A13 prescribes for a vendor whose renderer adapter has
not been written, pending validation against the A16 eval set.
Also adds the capability matrix Part H asks for. It says the
thresholds that depend on local capability — A12's failure count,
A14's consult budget, A15's manifest size — must be tuned "on the real
hardware", and that needs measurement rather than reputation. The
harness probes each model for the things that actually break: whether
it calls a tool at all, whether the plan it submits has substance
(llama3.2 emitted "{"), whether it can consume its own tool result,
and whether prompt-level JSON holds. Opt in with
NISSE_CAPABILITY_MODELS; it spends real tokens across several models.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing had ever assembled the agent. main.py was still the hello-world
placeholder from the scaffold, and no code outside the test suite
constructed an Orchestrator — which is how two fully built, fully
tested components came to be reachable from nothing: the delegation
harness, and the Gitea comment poller. Unit tests that construct a
component directly cannot catch that; only assembly can.
- config.py validates every variable before anything is built and
names all of them at once, rather than failing at first use twenty
minutes into a milestone on the call that mattered. Whitespace-only
counts as missing, since a token of spaces passes a truthiness check
and fails at the first request instead
- agent.py builds the whole graph and owns startup and shutdown order:
reconcile before accepting commands, and on stop, stop watching,
then stop talking, then close the store last
- The Gitea account is read via whoami rather than configured: it names
the identity every permission is evaluated against, and proves the
token works before anything depends on it
- Poller lifecycle is now a state-machine concern: watch the plan issue
while a gate stands, the PR while it is open, and stop on leaving.
The orchestrator owns when to watch; an injected factory owns how to
build one
- The executor reports each turn, because it runs synchronously inside
the transport callback: without it the agent is silent for a whole
milestone and a working run looks exactly like a hung one
- main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM
The entry-point tests changed shape: nisse-dev no longer greets, it
refuses to start unconfigured and says everything that is missing. Both
tests now run with a deliberately empty environment and a .env path
that does not exist, because the working directory's .env holds live
credentials and a test must never be one cwd away from starting a real
agent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes step 7.
- expert.py: one stateless call for a question needing no repo access.
Structured output is contracted at the prompt level, since vendors
differ on JSON enforcement, so the reply is parsed and validated
locally with one retry — tolerating a fenced code block, because
models wrap JSON habitually, but not missing fields
- The per-task budget is counted from the canonical log rather than
memory, so it survives a restart and cannot hand a circling executor
a fresh allowance. Exhaustion raises rather than answering: a caller
out of consults should get on with the work or escalate
- Consults are recorded as delegation_started/completed with role
consultant. A16 asks for a "consult/delegation eval set", and
reusing the composite events gets consult evaluation for free rather
than inventing a parallel record
- evals.py pairs starts with completions into eval records, with
verdicts kept outside the canonical log: the log records what
happened, a verdict is a later judgement about it, and judgements
get revised. Schema v3 adds the outcomes table, so a v1 store now
traverses two migrations — a better exercise of the hook than one
- ask_expert is offered to the executor as a tool only when a desk is
configured, in addition to the observable escalation triggers and
never instead of them (A12). Consulting is not mutating, so a
completion claim backed only by consults is still challenged
Found live: passing an empty message list and putting the question in
the system prompt fails outright — the Messages API requires at least
one non-system message. The question now travels as a synthesised user
message that is never appended to the log, which keeps the consult
stateless while remaining a valid request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A frontier model receives a scoped sub-task, works autonomously with
full tool access, and hands back; the local loop reads the handback
note and the file state, never the reasoning.
- Branch at delegation start, and abort discards it. That required
committing each turn's work onto the branch: without commits,
deleting a branch discards nothing, so "abort discards the work"
was a comforting phrase rather than a fact. The caller's own
in-flight changes are committed to their branch first, so an abort
cannot destroy work nobody delegated
- Hard caps on turns and wall clock; a breach is a defined failure
return, and its result is not usable, so the caller gates rather
than resuming on a half-edited tree
- Every event is checkpointed as it arrives, so a crash mid-delegation
leaves the branch at a known commit and a truthful transcript
- Handback is validated locally: blank fields or a non-boolean success
flag are refused and the delegation continues rather than accepting
a useless note
Two failure modes found by running it against a real model:
- A success claim with nothing changed was accepted. The handback plus
file state is the entire interface back, so a note describing work
that does not exist is worse than no note. Success now requires a
successful mutating tool call; reporting failure still does not,
since "this is impossible" is a legitimate finding
- The delegate invented the absolute path /workspace, wrote there, ran
its tests there, and handed back truthfully — with the repo empty
and git seeing nothing. Writes outside the workspace are now refused
with a message naming the real root, and both prompts state where
the workspace is instead of leaving the model to guess. This is not
sandboxing: work outside the repo is invisible to git, so the
milestone would publish an empty PR
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second renderer adapter, and the concrete case for A13's decision
to normalise in the renderer rather than on the wire. The two vendors
disagree structurally, not cosmetically: the system prompt is a
top-level field rather than a message, a tool call is a tool_use
content block rather than an array, a tool result is a block inside a
user message rather than a "tool" role, and tool schemas use
input_schema rather than parameters. A middlebox flattening those
would be lossy exactly where a long-running delegation lives.
- render_anthropic.py builds vendor-native /v1/messages bodies from
the canonical log, coalescing adjacent same-role blocks so a
tool_result lands directly after its tool_use
- Tool manifests are declared once in OpenAI shape and converted per
vendor, so adding a tool does not mean editing two places
- AnthropicPassthroughClient implements the same LLMClient protocol,
so callers pick a route rather than a different kind of client, and
maps provider tool ids to canonical ones at the boundary
- Dangling tool calls get a placeholder result here too; this API
rejects them more strictly than the OpenAI shape
- Vendor-opaque reasoning blocks are dropped for now with the reason
recorded: A10 wants them stored and never replayed across sessions,
and the canonical schema has no event for that yet
Live tests prove the body is accepted as sent, that a tool_result
turn round-trips, and — as A5 asks — that prompt cache accounting
survives the passthrough rather than being flattened by the proxy.
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>