A `review` target in the Dockerfile: its own user (nisse-review, uid
10002), git for its clones, state and clones in /var/lib/nisse-review,
configuration at /etc/nisse-review, and only its own agent installed.
release.yml publishes gitea.dongar.net/dev/nisse-review beside nisse-dev,
at the same version. review-bringup.sh builds and runs the image, as
bringup.sh runs nisse-dev's.
The test stage adds an agent package its base lacks, without its
dependencies, since the suite's own code imports every agent's. Without
that, the tests over nisse-dev's image no longer import since
test_review_live arrived. tests/deploy/test_images.py checks the image and
that the test stage leaves what it installed as it was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
Gitea now has two runners but there is one 4090. The e2e job joins one
concurrency group without cancel-in-progress, so a second PR's e2e waits
for the first. checks still run in parallel.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
The reviewer is read-only (R3), so Gitea refuses it resolving a thread.
Now each reply says whether it settles its thread, and a settled reply
carries <!-- nisse-review: settled -->. nisse-dev, as the author, resolves
every comment on that thread's line when it watches the PR, and doesn't
take the settled reply as a point to answer. scripts/resolve_settled.py
does the same for PRs made by hand.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
The operator chose single-space mode: the vault is mounted at /data, with
memory/ read-only over it, and there are no accounts or setup wizard;
Authelia in the operator's Caddy is the login. SB_NAME forces the mode, so
an empty vault can't boot the wizard. SilverBullet's own data folder is
gone. check now fails if the wizard could be open. The README says how to
make memory/ writable for a while and back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
SB_NAME alone put SilverBullet 2.11 in single-space mode: no accounts, no
setup wizard, and its /data folder served instead of the vault. The compose
file sets only PUID and PGID now, a test holds it to that, and the host
check fails on a single-space boot.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
The check went to the host on stdin, and the first docker compose exec read
the rest of it: the memory and firewall checks never ran, and it passed.
It is now its own file, sent as an argument, and ends with "all checks
passed" only when every check has run. Tests fake the host with a docker
that reads stdin.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
deploy/agent-host sets up the vault on nisse-host (infra/tofu#77) and
SilverBullet over it: the operator writes the vault, the agents' memory/
is read-only to it, and only the operator's Caddy reaches it, which
serves vault.dongar.net to the LAN. scripts/agent-host.sh installs and
checks it from here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
Gitea dismisses an approval made stale by a push in a job it queues on the
push (services/pull AddTestPullRequestTask, v28.1.0), and its merge check
counts approvals not yet dismissed. Measured here, the approval stands for
0.15-0.23 s after the push; the test's merge came a couple of API calls
later, so it usually lost the race, and once won it. The test now waits for
the dismissal, with a timeout, before expecting Gitea's refusal, and checks
the agent's own rule at once: the PR's head is read from the branch, so the
approval is for an older commit straight away.
Also notes in the Development Plan why the helper loop doesn't compact.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
Back on a PR it reviewed: a new head gets a later review of the commits
since (each one's diff, through the API) and of its earlier points; what
was said since gets replies, answered where it was said or skipped with a
reason. It signs off once and stays quiet while still happy. Past
tuning.review.max_rounds it sums up for the operator and goes quiet,
until a push or someone other than the PR's author joins in. An
operator's `stop` quiets it; `review` asks for a fresh review.
Reviews carry numbered markers and replies their point's key, so
recovery posts nothing twice.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
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