The driver can request_reimage(reason) when its build VM is broken past the usual fixes (before the go-ahead too: it asks, it doesn't act). The harness parks the work in progress (pushed to Gitea), then blocks the VM in the state store (schema 5, vm_blocks): no item on any project on it is taken up, across restarts. The operator hears once at top level, with the command to clear it, and each blocked item's thread gets a line. `vm ready <project>` lifts the block and trusts the VM's new host key; a reimaged VM is set up again by the toolchain step, and the item that found it goes first. loop.qnt gains the block (blocked_vm_runs_nothing) and a scenario; it found that a reimage lost work in progress, hence parking first. The walk requests reimages and recovers them, reimaged or not. The unreachable case reuses the block in #137. Architecture P5 is softer, and D3 describes the block. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
556 lines
34 KiB
Markdown
556 lines
34 KiB
Markdown
# Nisse — Development Plan
|
||
|
||
**Status: agreed (PR #84); reworked in PR #92 after the architecture revision (PR
|
||
#90).** How to get from the code as it is to the architecture in
|
||
`docs/Architecture.md`, in what order, and how each step is accepted. The
|
||
architecture says *what and why*; this document says *how and when*, and is revised
|
||
as work lands.
|
||
|
||
**The priority is a minimal new loop, running live.** Work is ordered to reach the
|
||
smallest end-to-end version of the conversational loop (M2.1) as soon as
|
||
possible; everything not needed for it waits. Each milestone is checked by an
|
||
automated end-to-end test against real resources, not by hand.
|
||
|
||
---
|
||
|
||
## 1. Where we are
|
||
|
||
A survey of `main` (2026-10-02) against the architecture. **Done** means it exists
|
||
and matches; **partial**, something exists but needs changing; **none**, not
|
||
started.
|
||
|
||
Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
|
||
|
||
| Section | Status | Notes |
|
||
|---|---|---|
|
||
| **L1** Work model | mostly done | Work items with an active pointer (M1.1); issue intake (M2.1); queue, parking and blocked items (M2.2) on the new loop; no grace period yet |
|
||
| **L2** Agent loop | mostly done | The loop (Phase 2), the only one since the switch-over (M2.8): driver runs, waits, `stop`, `abandon`, question runs |
|
||
| **L3** Authority | partial | Go-aheads bound to the restatement's revision, on the issue and its conversation (M2.1); action classes and the fast path, with `allow` bound to one dangerous action (M3.1); the trust dial, `trust.merge` (M3.3) |
|
||
| **L4** Classifier | mostly done | On the classifier role (the local model), for what the fast path doesn't know; fails closed; overrides export as an evaluation set (M3.2). Promoting its verdicts into rules is by hand |
|
||
| **L5** Conversation | partial | Matrix transport, mentions, lenient commands; on the new loop, a thread per item, named and ambiguous commands, plain-language go-aheads (M2.3); little explanation |
|
||
| **L6** Context | partial | Context fitting, per-role views and orchestrator facts (M1.2); no scope or agent memory |
|
||
| **L7** Canonical log | partial | Work item and actor on every event, `fact` (M1.1, M1.2); `action_verdict` (M3.1); no `knowledge_retrieved` |
|
||
| **L8** Durability | mostly done | WAL store, intents, boot reconciliation, PR adoption, comment redelivery; to move onto the new loop |
|
||
| **L9** Models and roles | mostly done | LiteLLM, eval capture; on the new loop: `driver`/`helper`/`expert`/`classifier` pinned per item, capability profiles in briefings, delegation and `ask_expert` with triggers (M2.6) |
|
||
| **L10** Knowledge vault | none | |
|
||
| **L11** Injection | partial | Operator allowlists on every channel; no classifier |
|
||
| **L12** Secrets, logging | partial | Structured state-change log; secrets in `.env`; no per-operation handout or redaction |
|
||
| **L13** Execution | none | Tools run locally in the agent's process. The token leaks into the clone (#89) |
|
||
| **D1–D2** Workflow | partial | On the new loop: issue intake, restatement and go-ahead (M2.1), labels (M2.3); no documents in the repo |
|
||
| **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) |
|
||
| **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) |
|
||
| **D7–D8** Deployment, projects | partial | Runs from a venv as a systemd unit on this box; project entries in config (M1.4); egress not enforced |
|
||
| **O**, **X** | none | No container image yet |
|
||
| `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) |
|
||
|
||
---
|
||
|
||
## 2. The environment today
|
||
|
||
- **Supporting services** (X5): Matrix, Gitea, LiteLLM, Vaultwarden, LLDAP with
|
||
Authelia. The infrastructure repository exists in Gitea.
|
||
- **Local inference:** llama-swap on an RTX 4090 host (320 W cap). The benchmark
|
||
(PR #55) chose `qwen3.8-27b` for local work.
|
||
- **Models by role today:** driver and helper `claude-opus-5-5`, expert
|
||
`claude-haiku-5-5`, classifier `qwen3.8-27b` (M3.2); the driver benchmark
|
||
measures where the local model fits beyond that.
|
||
- **Bring-up** runs on this development VM (`scripts/bringup.sh`), against test
|
||
resources: a test repository, the test Matrix room, a budget-capped LiteLLM key;
|
||
configuration in `/root/nisse-e2e/`.
|
||
- **Test repositories are made per run** by the test operator
|
||
(`nisse-testing-operator`, `NISSE_OPERATOR_TOKEN`), as an operator provides a
|
||
project's: seeded from `tests/integration/seed/`, the agent a collaborator,
|
||
`main` protected, a deploy key of its own; deleted afterwards
|
||
(`scripts/e2e-cleanup.sh` for leftovers). They replaced the shared sandbox
|
||
repository, whose state accumulated across runs.
|
||
- **A build VM for bring-up** is provided by the operator by hand until Terraform
|
||
(Phase 6); the agent reaches it over SSH with a key from `/root/nisse-e2e/ssh/`.
|
||
|
||
---
|
||
|
||
## 3. How we work
|
||
|
||
- **One PR per milestone,** each starting with a short implementation plan per
|
||
`CLAUDE.md`. A milestone marked **needs discussion** starts with a design
|
||
discussion first.
|
||
- **Every change comes with tests,** through `scripts/check.sh`.
|
||
- **Testing has three levels** (M1.6): every PR runs the checks (unit tests across
|
||
the runner's cores, 16 property walks from a moving seed, integration) and the
|
||
end-to-end **smoke** tests; a PR labelled `e2e:full`, a push to `main` and a
|
||
manual run add all 48 walks and the whole end-to-end suite; a published release
|
||
runs that full set before its image is built. The end-to-end tests drive the
|
||
real agent, every role on a local model (so they cost nothing per call), in a
|
||
repository of the run's own, with a scripted operator; CI runs them after the
|
||
checks pass. There is one runner, so a newer run for a
|
||
PR or branch cancels the one it supersedes (#126). Locally only the tests being written
|
||
(`-k`) or diagnosed (`--lf`); `scripts/e2e.sh` reports what a run spent. Manual
|
||
live runs are for the new loop's first runs and phase ends. Findings are filed as
|
||
issues and either fixed before moving on or deliberately deferred.
|
||
- **The Quint model moves with the loop:** `loop.qnt` models the architecture,
|
||
and its properties are tests (the property walk), changed in the same PR as a
|
||
rule they cover.
|
||
- **Bring-up runs on this box as root,** against test resources only, with today's
|
||
gates on until the classifier and trust dial are proven (Phase 3).
|
||
- **This plan is revised as we go,** in the same PR as the work that changes it.
|
||
|
||
---
|
||
|
||
## 4. Phases
|
||
|
||
### Phase 0 — Housekeeping (done)
|
||
|
||
**M0.1 Retire the old spec.** **Done.** **M0.2 A bring-up script.** **Done.**
|
||
|
||
### Phase 1 — Execution over SSH, and an end-to-end test
|
||
|
||
**M1.1 Work items in the state store.** **Done.**
|
||
|
||
**M1.2 Transcript views.** **Done.** (#78 confirmed live, and closed.)
|
||
|
||
**M1.3 Execution over SSH.** *Pre-approved.* **Done** (#93).
|
||
- `execute(host, command, *, secrets, stdin)` as the only way to run anything; file
|
||
tools over SSH; the host bound by the harness from the project (L13).
|
||
- The workspace lives on the build VM: cloned there, with no token in its
|
||
configuration; the push key handed to each push only (D4, #89).
|
||
- Every secret handed out is redacted from tool output (L12).
|
||
- Tests against a throwaway `sshd` container.
|
||
- Acceptance: the existing executor and delegation tests pass over SSH; a live run
|
||
of one milestone on the operator's build VM.
|
||
- Also landed, from that run (bring-up run 3): the push credential as an HTTPS
|
||
token or an SSH deploy key; flat harness branch names, after the executor made
|
||
a branch named after the task; **publishing only the work (#80)**, brought
|
||
forward from M2.1; credentials kept out of every repr.
|
||
|
||
**M1.6 The end-to-end test.** *Pre-approved.* **Done** (#95): `scripts/e2e.sh`.
|
||
- Automates bring-up run 3: the assembled agent, real models, the Gitea sandbox and
|
||
a real build host, with a scripted operator that answers whatever the agent asks.
|
||
- Asserts what was checked by hand: a PR on the sandbox containing only the work,
|
||
pushed with the push credential; no credential in the clone; the planner's next
|
||
turn citing the facts rather than the executor's turns.
|
||
- Runs against the current loop now, and against the new loop from M2.1.
|
||
|
||
**Phase 1 acceptance:** the end-to-end test passes against the current loop.
|
||
**Done:** it passes on the bring-up build VM.
|
||
|
||
**Moved after M2.1:** M1.4 (projects) and M1.5 (the container image). Neither is
|
||
needed for a minimal loop running live; both are done before the switch-over
|
||
(M2.8), when several projects and deployment start to matter.
|
||
|
||
### Phase 2 — The new loop, as a thin slice first
|
||
|
||
Built alongside `control.py`, reusing the state store, log, views, publisher,
|
||
pollers and recovery. The first milestone is the smallest end-to-end path; each
|
||
later one widens it and extends the end-to-end test (M1.6) to cover it.
|
||
|
||
**M2.1 The slice.** *Needs discussion* (the loop's shape in code). **Done** (#96,
|
||
#97, #98, #99; first live run: sandbox #255, follow-ups in #100).
|
||
- An issue assigned to `dev-agent` → a work item (D1); a restatement in the item's
|
||
thread; a go-ahead bound to its revision (L3, L5); one milestone through
|
||
`start_milestone` / `finish_milestone` on a harness branch (D4); the PR; a review
|
||
comment answered (D5); the merge; the item closed.
|
||
- Includes what carries over: a fresh workspace at item start and harness-owned
|
||
branches (D3, D4, #62); review intake, inline comments and the watch stall
|
||
(#69, #70); the review-fix check after merge. (Publishing only the work, #80,
|
||
landed early in M1.3.)
|
||
- `loop.qnt`'s properties for these paths become tests.
|
||
- Acceptance: the end-to-end test runs that path on the new loop, plus a crash
|
||
mid-publish; then a first manual live run.
|
||
|
||
**M2.2 The queue.** *Pre-approved.* **Done** (this PR). Several items: states,
|
||
park-and-switch with branches pushed, next-item order, blocked items from Gitea
|
||
dependencies, a closed PR handled (L1, D4). End-to-end: two issues and a dependency.
|
||
- Switching happens as soon as the active item waits; the grace period (§6, L1)
|
||
is left until a watching operator misses it. Order is "do #N next"; priority
|
||
labels and driver-chosen order wait for a need.
|
||
- The property walk covers several issues, dependencies and the operator's order,
|
||
and checks that parked work is never lost (every file an open item wrote is on
|
||
the workspace or in the forge).
|
||
|
||
**M2.3 Threads and labels.** *Pre-approved.* **Done** (this PR). Routing by thread,
|
||
ambiguous commands asked about (L5); `bug` grace periods, `discuss`, `design`;
|
||
questions and plain-language go-aheads (D1). End-to-end: a bug and a feature,
|
||
labelled and not.
|
||
- Commands naming their item ("abandon #14") apply to it anywhere; `go`, `approve`
|
||
and `abandon` alone at top level are asked about. `stop` is never an answer.
|
||
- A plain-language go-ahead is read by one model call (`GoAheadReader`, on the
|
||
driver's model until the classifier role, M2.6); questions never reach it.
|
||
- `bug` waits `tuning.loop.bug_grace_s` (default 10 minutes) after its restatement;
|
||
any reply stops the countdown. `discuss` isn't offered `restate` until the
|
||
operator has spoken; `design` is steered by the driver's instructions.
|
||
- Labels are read at intake; a label changed later is not picked up yet.
|
||
|
||
**M2.4 Multi-milestone work and documents.** *Pre-approved.* **Done** (this PR).
|
||
Build plans in the repository, `ARCHITECTURE.md` kept current, waiting for merges
|
||
between milestones (D2, D4). End-to-end: a two-milestone feature.
|
||
- Every milestone branch is cut from the latest default branch, so the next one
|
||
builds on the merged previous and a redo drops a closed PR's work. While the
|
||
item's PR is open, `start_milestone` is refused, the PR's state read from Gitea
|
||
at that moment. `finish_milestone(final)`: earlier PRs say "Part of #N", the
|
||
last "Closes #N".
|
||
- `close_item` with the item's PR still open waits for the merge.
|
||
- Documents are the driver's instructions only; 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.
|
||
- Deferred: a milestone with no changes that wasn't expected to change code is
|
||
still raised with the operator (D4).
|
||
|
||
**M2.5 Conversations and new projects.** **Done** (#106 and this PR). *Needs discussion;* the design was agreed
|
||
in this PR (`general` scope, L1, L5; the new-project sequence, D1). Work started in
|
||
conversation, record issues carrying their item's ID, the new-project flow and its
|
||
project entry (D1, D8, #29). After M1.4. End-to-end: a small new project, in a
|
||
repository the test operator creates under its own account for the run and
|
||
deletes afterwards.
|
||
- **PR A, conversations — done (this PR).**
|
||
- A top-level message that isn't a command is a `general` item, threaded under
|
||
it; the driver gets `reply`, `ask_human` and `propose_work`, with no workspace.
|
||
- Agreed work (a go-ahead, or a plain yes, on the proposal) is filed under an
|
||
intent as a record issue carrying the new item's ID. Its item starts agreed,
|
||
with no second restatement. Intake skips any issue carrying an ID, and boot
|
||
settles a filing cut off by a crash.
|
||
- A conversation closes after `tuning.loop.general_idle_s` without a message; a
|
||
reply in its thread then starts a new one that continues it.
|
||
- **PR B, new projects — done (this PR).**
|
||
- `propose_project(name, purpose, decisions)` goes up for a go-ahead.
|
||
- On go, the checklist: repository, protection, collaborator, build VM, deploy
|
||
key, and the project entry as YAML to fill in.
|
||
- On "done", the agent reads its configuration again and adds the new project
|
||
live (only additions). It then checks the access: the repository's
|
||
permissions and protection as the agent sees them, then in practice on a
|
||
throwaway branch with an empty commit (a branch push must succeed, a push to
|
||
the default branch and merging its own PR must be refused); the branch and PR
|
||
are removed. Anything that fails is said, and nothing starts.
|
||
- When all is well: the tracking issue with the distilled decisions, 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.
|
||
|
||
**M2.6 Roles and capability profiles.** *Pre-approved.* **Done** (this PR).
|
||
`driver`, `helper`, `expert`, `classifier` (L9); escalation triggers; delegation and
|
||
`ask_expert` on the new loop.
|
||
- `roles.driver` (required since M2.8; until then, unset meant the planner's
|
||
model) and `roles.classifier` (unset: the expert's), pinned per item; the driver and the go-ahead reader run on the item's.
|
||
- `profiles:` in configuration (tools, strengths, weaknesses), used in briefings:
|
||
the driver is told what its helper and expert are good at, the helper who will
|
||
read its handback; never a model's name.
|
||
- `delegate(task)` and `ask_expert(question)` for the driver. A helper works on a
|
||
branch off the milestone's; its work comes back as uncommitted changes on a
|
||
handback and is discarded otherwise, so harness branches never hold commits only
|
||
the clone has (the walk found a fix stranded that way when a PR merged mid-help).
|
||
- Triggers: `failure_limit` failed commands in a row, or the turn cap, mid-
|
||
milestone, call a helper in once per milestone; if it fails too, the item waits
|
||
on the operator as before. A delegation cut off by a crash is discarded at boot.
|
||
- Evaluation capture: delegations, `ask_expert` and the reader's verdicts.
|
||
- Deferred: the context-won't-fit trigger (L6); the action classifier (M3.2).
|
||
|
||
**M2.7 Conversation.** *Pre-approved.* **Done** (this PR). Explanations, progress
|
||
in prose, asking mid-work (L5, #42, #77).
|
||
- Questions never change state: a message ending in `?` to a waiting project item
|
||
is answered in a question run (`reply` and look-only tools), and the item goes
|
||
back to the very wait it was in, so a restatement up stays up and a go binds to
|
||
it.
|
||
- A restatement gives its reasons and the main alternative; a revision says what
|
||
changed (`restate(changed)`, sent back without it).
|
||
- Progress: 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.
|
||
- Deferred: streaming (#42); the question property in `specs/loop.qnt` (the walk
|
||
checks it against the code).
|
||
|
||
**M1.4 Projects** (D8). *Pre-approved.* **Done** (this PR).
|
||
- The project entry in configuration: repository, default branch, build VM, label
|
||
defaults, secrets by name with their visibility, egress additions.
|
||
- Acceptance: the bring-up configuration is one project entry; a second project
|
||
needs only a second entry.
|
||
- `projects: {name: entry}`. Each project has its own clone, workspace, push
|
||
credential and milestones. Issues are named `project#14` when two projects could both have
|
||
`#14`, and a bare `#14` that matches two is asked about. Switching projects parks
|
||
the one worked on last.
|
||
- Secrets are by `${VAR}` reference only, `commands` or `harness`. `commands`
|
||
secrets reach `run_command` as environment variables, redacted from output;
|
||
`harness` secrets are held for later. Egress is recorded, not enforced (D7).
|
||
- Deferred: config reload without a restart (with M2.5's entry step if wanted);
|
||
the trust dial's configuration (M3.3).
|
||
|
||
**M1.5 The agent as a container image.** *Pre-approved.* **Done** (#115; accepted
|
||
2026-10-07: `v0.1.0`'s published image passed the whole e2e suite, 9 of 9, run with
|
||
the release's own tests over it).
|
||
- A Dockerfile for `nisse-dev`, stateless with a volume for state; a Gitea Actions
|
||
workflow that builds it and pushes to Gitea's registry on each release tag (X4).
|
||
- `bringup.sh` runs the image instead of a venv.
|
||
- Acceptance: a tagged release produces an image; the end-to-end test runs from it.
|
||
- The image runs as its own user; state in `/var/lib/nisse-dev`, configuration
|
||
mounted at `/etc/nisse-dev/nisse-dev.yaml`, secrets as environment variables,
|
||
keys mounted read-only. Its `test` stage carries the tests over the installed
|
||
package, for `scripts/e2e.sh --image REF`. Images are built only on release:
|
||
`release.yml` pushes `gitea.dongar.net/dev/nisse-dev:<version>` and `:latest`
|
||
on a published release (`REGISTRY_TOKEN`, the `dev` organization's).
|
||
- Acceptance runs the tests over a published image: `docker build --target test
|
||
--build-arg TEST_BASE=gitea.dongar.net/dev/nisse-dev:<version>` from the
|
||
release's checkout, then `scripts/e2e.sh --image`; the test stage adds only the
|
||
test tools, so the agent under test is the one published.
|
||
- The live bring-up's switch to the image waits for the next live test (it needs
|
||
its own test repository too); `bringup.sh plan` shows what it will run.
|
||
|
||
**M2.8 Switch over.** *Pre-approved, after acceptance.* **Done** (this PR, after
|
||
the live session's fixes in #119 and #122). `nisse-dev` runs only the loop.
|
||
- Removed: `control.py`, `planner.py`, `executor.py`, `approvals.py`, `gates.py`,
|
||
`publish.py`, `poller.py`, the old assembly (`agent.py`, folded into
|
||
`loop/build.py`), the state store's state-machine view, their tests, the old
|
||
end-to-end and live planning tests, and `orchestrator.qnt` with its scenarios.
|
||
- Configuration: `loop:`, `roles.planner`, `roles.executor` and
|
||
`tuning.executor` are refused with what to use instead; `roles.driver` is
|
||
required, and `tuning.driver` takes the driver's 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)`.
|
||
- A delegation on the loop records what its helper's alias resolved to (L9), as
|
||
the old path's did.
|
||
|
||
### Phase 3 — Authority
|
||
|
||
**M3.1 Action classes and the fast path.** *Needs discussion.* **Done** (this PR).
|
||
Harmless, contained and dangerous actions per agent (P3, D6), static fast-path rules.
|
||
- `nisse_core.actions` (the shared part, `nisse-core`'s first code): `Action`,
|
||
`Verdict`, and reading shell commands statically, segment by segment (`cd`,
|
||
wrappers like `sudo` and `timeout`, `sh -c`); what can't be read that way is
|
||
unknown, and a line is as bad as its worst command.
|
||
- `nisse_dev.loop.actions`, the rule table: reading, checks and tests harmless;
|
||
workspace edits, local git and installs on the build VM contained; pushing,
|
||
other hosts, uploads, publishing, the forge's tools, deleting outside the
|
||
workspace and wrecking the VM dangerous; changing branches by hand refused.
|
||
- Every execution call is screened before it runs, the driver's and the
|
||
helper's. A dangerous one 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.
|
||
- Every verdict is an `action_verdict` event (no model sees it) and an oplog
|
||
line; `details` shows the counts and every action not simply run.
|
||
- Decided: unknown actions run, logged, until the classifier (M3.2), since this
|
||
is a project in progress, not production.
|
||
- Deferred: the classifier and in-scope judgement (M3.2), the trust dial
|
||
(M3.3), egress enforcement (X2).
|
||
|
||
**M3.2 The classifier.** *Needs discussion.* **Done** (this PR). L4, its model,
|
||
fail-closed verdicts, overrides as an evaluation set.
|
||
- `nisse_dev.loop.classifier`: one model call per action the fast path calls
|
||
unknown, on the item's pinned `classifier` role. 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.
|
||
- Decided: no different-model requirement (separate context is what L4's
|
||
independence needs; the verdicts record the model, so correlated misses can
|
||
be measured); the local model to start with; verdicts cached per item,
|
||
agreed revision and action.
|
||
- `action_verdict` gains `source` (fast path or classifier), `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`
|
||
(`qwen3.8-27b-nothink` and `claude-haiku-4-5`: 9 of 9 each).
|
||
- The fast path's delete rule: on the build VM, deleting is contained; only
|
||
what breaks the host stays dangerous (decided with the operator).
|
||
- Deferred: promoting verdicts the classifier keeps agreeing on into rules (by
|
||
hand, from exported cases); the trust dial (M3.3).
|
||
|
||
**M3.3 The trust dial.** *Needs discussion.* **Done** (this PR). Per project (D8);
|
||
agent merging behind protection it can't satisfy, verified by a test (D4, D5, #65).
|
||
- `trust.merge` per project: `operator` (the default) or `approved`: the harness
|
||
(never the model) merges a PR in the PR watch once an approving review from a
|
||
`gitea.operators` account is on the PR's current commit, its checks pass (no
|
||
checks at all counts as passing; pending waits) and it's mergeable; then the
|
||
item goes on as after any merge. Each reason to hold off is said once.
|
||
- Protection, verified at start per `approved` project: a throwaway branch and
|
||
PR made through the API (never the workspace), whose merge must be refused;
|
||
if it isn't, the operator merges until a restart. Real Gitea is tested too
|
||
(`test_trust_live.py`): refused unapproved, merged once approved, refused
|
||
again after a commit lands on the approved PR.
|
||
- Test and bring-up repositories get the protection: approvals from an
|
||
operators-only whitelist, stale approvals dismissed, the agent on the merge
|
||
whitelist.
|
||
- Decided: two levels only (no merging on checks alone, D5); verification by
|
||
behaviour, since a collaborator can't read protection and must never set it.
|
||
Deferred: a project-setup script for the operator (protection, labels) and a
|
||
label check, #130.
|
||
|
||
### Phase 4 — The environment
|
||
|
||
**M4.1 Toolchain preflight, M4.2 the toolchain step, M4.3 environment repair.**
|
||
*Pre-approved.* All on the build VM (D3).
|
||
|
||
**M4.1** is **done** (this PR). A model works out and sets up what a project
|
||
needs; code makes sure that happens, bounds it and checks the result.
|
||
- 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, said, and the item resumes from what
|
||
it pushed) and a per-project marker (`~/.nisse/<project>.ready`).
|
||
- No marker (a new project, a new or wiped VM): the item's driver model runs
|
||
the preflight first, with its own prompt (the project's toolchain, not the
|
||
template's basics or tools that act on other systems; never credentials),
|
||
the execution tools (screened as ever) and `toolchain_ready`. The harness
|
||
runs each reported version command itself (version-shaped commands only,
|
||
wherever they come from) 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 gets the last toolchain as its starting point.
|
||
- Later runs check the versions again; a change is a fact and a thread line,
|
||
and the PR body says what the work was tested with.
|
||
- A changed host key is its own failure: the item waits for the operator's
|
||
`trust new host key`, then the VM is set up again.
|
||
- Decided with the operator: the model judges toolchains (the harness can't
|
||
without it), preflight runs on the driver's model, gaps don't block. Turning
|
||
off unattended upgrades and the template's baseline are template choices.
|
||
|
||
**M4.2** *(revised with the operator: in place of recording installs).*
|
||
**Done** (this PR). The
|
||
preflight becomes **the toolchain step** (D3): make the VM and the project's own
|
||
files agree, with the repository as the record rather than a harness-kept one.
|
||
- Started by the harness when the VM isn't set up for the project (M4.1's
|
||
checks): it sets the VM up from the repository and edits nothing there.
|
||
- Requested by the driver (`toolchain_step(reason)`) once work is agreed: it
|
||
installs, then brings pins, setup docs and CI configuration in step; the edits
|
||
land in the current milestone's PR. Refused before the go-ahead.
|
||
- Run in a transcript of its own; the driver sees only its report.
|
||
- With it, D1's rule made structural: until the go-ahead the fast path refuses
|
||
anything that isn't harmless, so needs go in the restatement.
|
||
|
||
**M4.3** *(revised with the operator: a broken VM blocks its projects).*
|
||
**Done** (this PR). Cleaning caches and reinstalling dependencies were already
|
||
contained and reported as the actions they are, so what is left is a VM broken
|
||
past the usual fixes (D3).
|
||
- The driver can `request_reimage(reason)`, before the go-ahead too (it asks,
|
||
it doesn't act). Its prompt says to try the usual fixes first, then ask rather
|
||
than go on diagnosing.
|
||
- The harness parks the work first (pushed to Gitea, as for a switch; a VM too
|
||
broken to push loses it, and the operator is told), then **blocks the build
|
||
VM**: a durable block per host, so every project on it waits and none of its
|
||
items is taken up, across restarts. Said once at top level with the projects
|
||
blocked and the command that clears it, and in each blocked item's thread.
|
||
- The operator's `vm ready <project>` (no name needed when only one is blocked)
|
||
lifts the block and trusts the VM's new host key. A reimaged VM has no marker,
|
||
so the toolchain step sets it up again before work; one said ready without a
|
||
reimage goes straight on. The item that found it goes first.
|
||
- `loop.qnt` gained the block (`blocked_vm_runs_nothing`); it found that work in
|
||
progress was lost on a reimage, hence parking first. The walk requests
|
||
reimages and recovers them, reimaged or not.
|
||
- Next: an unreachable VM blocks through the same mechanism, unblocked by a slow
|
||
background retry once it is back (#137). Later, nisse-ops reimages a VM itself
|
||
(M6.2, M7.6); a changed address means editing `build_host` and restarting.
|
||
|
||
### Phase 5 — Memory
|
||
|
||
**M5.1 The vault.** *Pre-approved.* The folder, per-agent mounts, control tools,
|
||
atomic writes and provenance (L10).
|
||
|
||
**M5.2 Agent memory** (*needs discussion*) and **M5.3 scope memory**
|
||
(*pre-approved*): when notes are written and read, verbatim corrections (L6).
|
||
|
||
**M5.4 The operator's copy.** *Pre-approved.* Syncthing, directions per folder.
|
||
|
||
### Phase 6 — Production
|
||
|
||
**M6.1 The agent host.** *Needs discussion.* Containers, volumes, snapshots,
|
||
per-agent networks (X1, X2, X4).
|
||
|
||
**M6.2 Build VMs by Terraform.** *Needs discussion.* The template, VLAN and egress
|
||
(D7, X2), collection when idle.
|
||
|
||
**M6.3 Secrets and identity.** *Pre-approved.* Per-agent secret sets on the host,
|
||
LLDAP principal (L12).
|
||
|
||
**M6.4 Delivery.** *Pre-approved.* Watchtower, restarts gated on an idle flag (X4).
|
||
|
||
**M6.5 The browser tool.** *Pre-approved.* On the internal test network (D7).
|
||
|
||
### Phase 7 — `nisse-core` and `nisse-ops`
|
||
|
||
**M7.1 Extract `nisse-core`.** **M7.2 MCP servers** (#37). **M7.3 Approved step
|
||
sequences** (O2). **M7.4 Ops intake and duties** from the infrastructure
|
||
repository (O5). **M7.5 The ops tool VM and deployment** (O6). **M7.6 Requests
|
||
between agents:** `nisse-dev` asking for build VMs (L1, O5). All *needs discussion*
|
||
except M7.1.
|
||
|
||
### Not yet scheduled
|
||
|
||
From the architecture's open questions and older issues: filing issues from
|
||
conversation; stacked PRs; releases; `nisse-ops` issuing credentials; a server
|
||
database; recovering the agent host (an ops issue); more than one active work item;
|
||
vision; one local model for every agent; several forges (#26); a room per project
|
||
(#28).
|
||
|
||
---
|
||
|
||
## 5. Open issues and where they land
|
||
|
||
| Issue | Lands in |
|
||
|---|---|
|
||
| #26 multiple forges | Not yet scheduled |
|
||
| #27 per-project state, clone on use | M1.3 (clone on the build VM), M1.4 (projects) |
|
||
| #28 room per project | Not yet scheduled (threads, M2.3, may make it unnecessary) |
|
||
| #29 create a project from scratch | M2.5 (ownership settled by D4, D8) |
|
||
| #37 MCP servers | M7.2 |
|
||
| #42 per-turn commentary | M2.7 |
|
||
| #62 branch at execution start | M2.1 |
|
||
| #65 agent merges approved PRs | M3.3 |
|
||
| #69 inline review comments | M2.1 |
|
||
| #70 watch stall | M2.1 |
|
||
| #77 conversational agent | M2.3 (questions), M2.7 |
|
||
| #137 block on an unreachable build VM | After M4.3, which built the block |
|
||
|
||
## 6. Implementation notes
|
||
|
||
Mechanism detail moved out of `docs/Architecture.md` in PR #90, which keeps only
|
||
decisions and their reasons. Keyed by the architecture section each note
|
||
implements; a milestone touching that section uses the note, and changes it here
|
||
if the implementation differs.
|
||
|
||
- **L1 switching:** the grace period before switching away from an item waiting
|
||
on the operator defaults to 10 minutes. When the driver chooses the next ready
|
||
item and the choice fails or is invalid, fall back to the oldest.
|
||
- **L3 revision stamping:** in Matrix, a reply is stamped with the revision whose
|
||
message had been sent when it arrived; on a Gitea issue, with the latest
|
||
proposal comment older than the reply.
|
||
- **L5 transport:** the `Transport` interface is send, ask and receive. Intake
|
||
backoff is exponential, capped at a few minutes.
|
||
- **L6 fitting order:** first shorten old tool results to their first and last
|
||
lines, then drop the oldest events with a note saying so; never the system
|
||
prompt, the current plan or the most recent turns. View labels:
|
||
`[orchestrator]`, `[operator, Matrix]`, `[operator, comment on PR #N]`,
|
||
`[helper report]`, `[driver]` narration.
|
||
- **L7 rendering:** content is stored as bytes or local paths and uploaded or
|
||
inlined at render time, never as provider handles. Token budgeting and
|
||
prompt-cache breakpoints are render-time concerns, never stored. Compaction
|
||
summaries are first-class events so a model change never inherits opaquely
|
||
compacted history. A reasoning "session" is a delegation, including one resumed
|
||
after a crash.
|
||
- **L8 state store:** SQLite in WAL mode, each change committed and fsynced before
|
||
the loop continues. A delegation's cut is clean when its last event is a
|
||
completed result, its branch is at the expected commit, its caps are unbreached
|
||
and external truth matches the log. "Anything else" offers continue, re-plan or
|
||
abandon.
|
||
- **L9 local models:** request `tool_choice` only of models whose server declares
|
||
it can enforce it; otherwise the loop enforces the contract (an explicit
|
||
`finish`, prose treated as a violation). Briefings use the capability profile,
|
||
never a model name or "weaker model".
|
||
- **L10 vault:** atomic writes are write-then-rename. Obsidian's settings folder
|
||
(and `.git`, if history is kept) are not synced. A failed write is retried, or
|
||
recorded and reconciled later.
|
||
- **L12:** secrets reach a command by environment or stdin. Headless agents get an
|
||
Authelia single-factor exception scoped by source address, group and target.
|
||
Logs are shipped to the host journal, not kept in containers.
|
||
- **D3 preflight:** a project's needs are read from its files (`Cargo.toml`,
|
||
`pyproject.toml`, `package.json`) or a declaration in the repository.
|
||
- **D4 publishing:** standard excludes are configured in each clone, and only what
|
||
the work produced is committed. The push credential is handed to each push
|
||
only: a deploy key over stdin to a short-lived `ssh-agent` on the build VM, or
|
||
a push-only account's HTTPS token as that command's secrets, read by a
|
||
credential helper that names environment variables, never values.
|
||
- **D7:** test services use ports 18000–18099, so opening access later is a
|
||
firewall change. The inference host is behind LiteLLM, so it is not a second
|
||
egress exception.
|
||
- **X4:** the agent host's state dataset is ZFS, snapshotted on a schedule and on
|
||
each approval. Tool VM templates use Terraform's `bpg/proxmox` provider with
|
||
cloud-init. Containers restart unless stopped.
|
||
- **X5:** the local inference host is llama-swap on an RTX 4090 machine.
|