Files
nisse/docs/Development-Plan.md
T
dongarandClaude Opus 5.5 0660fa3c51
CI / checks (push) Skipped
CI / e2e (pull_request) Failing after 49m48s
CI / checks (pull_request) Successful in 10m0s
CI / image (pull_request) Successful in 27s
The agent as a container image (M1.5)
- Dockerfile: built with uv into /opt/nisse (installed, not editable),
  run on python:3.12-slim with openssh-client as user nisse; stateless,
  with state in a /var/lib/nisse-dev volume, config mounted at
  /etc/nisse-dev/nisse-dev.yaml, secrets as environment variables, keys
  mounted read-only. 202 MB. Its `test` stage adds the tests and their
  tools over the installed package (UV_NO_SYNC, so `uv run` never swaps
  in the source tree); the default target is the agent.
- scripts/e2e.sh --image REF runs the suite inside an image's test stage,
  on its own sshd.
- CI builds both images on every PR (nothing pushed); release.yml pushes
  gitea.dongar.net/dongar/nisse-dev:<tag> and :latest on a v* tag, with
  the REGISTRY_TOKEN secret.
- scripts/bringup.sh builds the code under test into an image and runs it
  in the systemd unit (root, $E2E mounted at its own path); `plan` prints
  what it would run; the build-VM set-aside reads the projects: config.

Checked: the image refuses to start without a config, runs as uid 10001;
one e2e test passed from the test stage (2m45s, $0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 19:50:59 +00:00

422 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | partial | `control.py`, a fixed state machine with no-stranding, `stop`, `abandon`. Replaced by the new loop (Phase 2) |
| **L3** Authority | partial | Revision binding and arrival stamping (`gates.py`); scope is still plan approval, not the issue and its conversation |
| **L4** Classifier | none | |
| **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); no `classifier_verdict`, `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 | `orchestrator.qnt` (current loop), `loop.qnt` (the architecture, #91) |
---
## 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:** executor `qwen3.8-27b`, planner and helper
`claude-opus-5-5`, expert `claude-haiku-4-5`. Phase 2 moves to driver and helpers.
- **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`.
- **The end-to-end test runs on every PR** (M1.6): the real agent, every role on a
local model (so it costs nothing per call), a repository of the run's own and a
real build host, driven by a scripted operator. CI runs it as its own job on pull
requests, after the checks pass. There is one runner, and a run a newer push
supersedes has to be cancelled by hand. 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 models move with the loop:** `orchestrator.qnt` while `control.py`
runs, `loop.qnt` for the new loop, whose properties become tests as each piece
lands (Phase 2).
- **`control.py` gets fixes only,** and only for what blocks a live run. New
behaviour goes into the new loop.
- **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` (unset: 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}`. The old loop runs exactly one project and refuses
more. On the new loop 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.* **Built** (this PR);
accepted once a tag's image passes the e2e suite.
- 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`. CI builds both on every PR;
`release.yml` pushes `gitea.dongar.net/dongar/nisse-dev:<tag>` and `:latest`
(`REGISTRY_TOKEN`).
- 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.* `nisse-dev` runs the new
loop; `control.py`, its tests and `orchestrator.qnt` are removed.
### Phase 3 — Authority
**M3.1 Action classes and the fast path.** *Needs discussion.* Harmless, contained
and dangerous actions per agent (P3, D6), static fast-path rules.
**M3.2 The classifier.** *Needs discussion.* L4, its model, fail-closed verdicts,
overrides as an evaluation set.
**M3.3 The trust dial.** *Needs discussion.* Per project (D8); agent merging behind
protection it can't satisfy, verified by a test (D4, D5, #65).
### Phase 4 — The environment
**M4.1 Toolchain preflight, M4.2 recording installs, M4.3 environment repair.**
*Pre-approved.* All on the build VM (D3).
### 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 |
## 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]`, `[executor]` 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.