diff --git a/CLAUDE.md b/CLAUDE.md index 6418e64..8b02942 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ Monorepo for two self-hosted AI agents (`nisse-dev`, `nisse-ops`) over a shared library (`nisse-core`), one package per directory. **Read the architecture before making design decisions.** The system-level -specification is `docs/Architecture-Build-Spec.md`. Requirements that look like +specification is `docs/Architecture.md`, and how it gets built is +`docs/Development-Plan.md`. Requirements that look like free choices — gating structure, state durability, LLM access paths, permission policy — are usually settled there. diff --git a/README.md b/README.md index 4605a51..bf2d98d 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ return. | Package | Role | |---|---| -| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part A) | -| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part B) | -| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part C) | +| [`nisse-core/`](nisse-core/) | Shared library both agents sit on (Part L) | +| [`nisse-dev/`](nisse-dev/) | Dev Agent — plans and implements software projects (Part D) | +| [`nisse-ops/`](nisse-ops/) | Sysadmin Agent — operates infrastructure (Part O) | One repository, three packages. The library does not exist as code yet: it is extracted from `nisse-dev` once that agent works (build-order step 10), as a @@ -17,6 +17,6 @@ package split within this tree. ## Architecture The system-level specification lives at -[`docs/Architecture-Build-Spec.md`](docs/Architecture-Build-Spec.md). It covers the +[`docs/Architecture.md`](docs/Architecture.md). It covers the shared library, both agents, the supporting services, the build order, and the remaining open decisions. diff --git a/docs/Architecture-Build-Spec.md b/docs/Architecture-Build-Spec.md deleted file mode 100644 index e6be81a..0000000 --- a/docs/Architecture-Build-Spec.md +++ /dev/null @@ -1,1507 +0,0 @@ -# Nisse — Build Specification - -A build-oriented spec for two self-hosted AI agents sharing a common library. This -document states **requirements per component** so major pieces can be broken out and -built independently. It is deliberately lighter on the supporting services (Matrix, -Gitea, Vikunja, Outline, LiteLLM) and heaviest on the **agent library** and the -**two agents**, which is where the real design work lives. - -**Document history.** The Tiered Inference amendment has been folded into this -document and no longer exists separately: its A-series components appear as A10–A16, -its network and credential revisions in A5 and B3, and its revised build order as -Part G. The inference-hardware question it raised has since been resolved in -principle (Part H: a 4090-class host). The MCP Deployment amendment has likewise been -folded in: the every-agent-gets-a-VM rule, per-instance egress policy, and MCP server -placement now form Part F1–F3; its consequences appear in Part 0 (site identifiers), -A7, B3, C2, C3, C5, the Part F4 Watchtower carve-out, and Part H. - ---- - -## 0. Naming - -The project is **Nisse**, after the Nordic farm spirit that keeps the place running -overnight and expects to be treated well in return. Three artifacts: - -| Artifact | Name | Role | -|---|---|---| -| Shared library | `nisse-core` | The spine both agents sit on (Part A) | -| Dev Agent | `nisse-dev` | Plans and implements software projects (Part B) | -| Sysadmin Agent | `nisse-ops` | Operates infrastructure (Part C) | - -Throughout this document, **Dev Agent** and **Sysadmin Agent** name the *roles* in -conceptual discussion; `nisse-dev` and `nisse-ops` name the *artifacts* — packages, -container images, VM hostnames, Matrix identities, and service accounts. Use the -same string across all of those: the VM, the container, the Caddy host, the Matrix -account, and the Bitwarden machine account for a given agent all share one name, so -that grepping the infrastructure for `nisse-ops` finds everything. - -**Sites** are identified by their domain stem: `garrett-hall` (Arlington, hosts under -`garrett-hall.com`) and `hartman-hut` (Cross Plains, hosts under `hartman-hut.com`). -As with agent names, one string is used everywhere a site appears. For an MCP server -instance (F3), that means its container name, its Vaultwarden machine account, and -its MCP tool prefix. - -The three artifacts live as three packages in **one repository, `nisse`** (decision -recorded in Part H). Directory and package names carry the identity. If mirrored to -GitHub, it goes under a personal account rather than an org — `github.com/nisse` is -squatted and GitHub does not reclaim inactive usernames. `nisse-core` is free on -PyPI if packages are ever published. - ---- - -## 0.1 What We're Building - -Two agents: - -1. **Dev Agent** — `nisse-dev` (build first) — plans and implements software - projects, produces Gitea PRs for human review, reacts to review comments and - commits. -2. **Sysadmin Agent** — `nisse-ops` (follow-on) — operates and maintains - infrastructure, driven by tickets, gated on high-consequence actions. - -Both are thin applications over a shared **`nisse-core`** library. The library is -*extracted from the Dev Agent after it works* — not designed up front for two -consumers that don't exist yet. Build the Dev Agent as if the library were internal, -then lift the seams that proved real. - -There is one deliberate, bounded exception: the **work model's identity scheme** -(0.2) is settled for both agents ahead of extraction. Identifiers end up in the state -schema, branch names, the operational log (A9), the transcript (A10), and external -records. Changing those after extraction would mean migrating two agents and their -stored state. Only identity is designed ahead. Behaviour is still built by each -agent as it needs it, so nothing in 0.2 requires the Dev Agent to implement duties, -deduplication, or concurrency. - -The three artifacts live in **one repository** (decided — Part H), so the -extraction step in the build order is a package split within one tree, not a repo -split. - -### Guiding principles (apply to everything below) - -- **Human-gated autonomy.** Agents work independently once told to start, but never - cross key boundaries (start implementation, apply infrastructure change) without - explicit sign-off. -- **No per-command approval.** Within an approved unit of work, execution is - unattended. Control lives at the *boundaries*, not per action. -- **Isolation matched to blast radius.** Dev Agent is disposable and sealed; Sysadmin - Agent is trusted with live-infrastructure reach. They never share a running - instance. -- **Stateless image, stateful volumes.** Containers are replaceable; durable state - lives on named volumes and must survive both redeploys and unexpected reboots. -- **Structured channels for machines, prose channels for humans.** Never route - machine event intake through human-facing formatters (chat bridges). -- **Own the stack.** Everything self-hostable stays inside the network where practical. - ---- - -## 0.2 The Work Model: Duties, Work Items, and Scopes - -Every agent's work is organised into three kinds of unit. They define what state -belongs where, how work is identified, and what the operator is resuming when they -come back to something. Components throughout this document refer to them. - -| Concept | What it is | Lifetime | Dev Agent | Sysadmin Agent | -|---|---|---|---|---| -| **Duty** | A standing responsibility that produces work items | Indefinite, recurring | None yet; the operator is the only source | Scheduled checks, log monitoring, alert intake | -| **Work item** | One effort with a lifecycle: opened, worked (often through gates), closed | Opened → closed | Building a feature through arch plan → milestones → PRs | Diagnosing one failure; answering one question | -| **Scope** | What a work item operates on, and the context that persists across work items there | Indefinite | A repo | A site and service, e.g. `garrett-hall` Proxmox | - -### Duty - -- **A duty produces work items. It is not one itself.** A run of a duty (one log - analysis, one scheduled check) is read-only and yields findings. A finding that - needs attention becomes a work item. -- **The operator is always an implicit source** of work items, for every agent. -- **Deduplication is a duty's responsibility.** When a later run finds the same - failure, it attaches to the existing open work item instead of opening a second - one. That needs a stable fingerprint per finding, checked against open work items. - This is the same idempotency concern A2/A3 address for side effects. - -### Work item - -- **The unit of state.** A work item owns its state-machine position, gates and plan - revisions, pending questions, pinned role mapping (A11), and its transcript (A10). -- **Identity is minted locally.** Every work item gets a local ID when it opens. - This is the `task_id` of A9: a work item's ID and its correlation ID are one value. -- **An external record is optional, and linked.** A work item *may* be tied to a - durable external record: a Gitea issue for the Dev Agent, a Vikunja ticket for the - Sysadmin Agent. Many will not be; a general question to either agent is a work - item with no record. A work item can also gain a record partway through, for - example a question that turns into a change to make. -- **Where the spec requires a record, it has one.** Dev plan gates are externalized - to Gitea (Part H), and ticket-driven ops work has its ticket (C4). When a record is - linked, external truth wins on divergence (A2), and gate state can be re-derived - from it after state loss. Whether *every* gated work item must have a record is - open (Part H). -- **Work items are not nested.** A milestone is a phase *within* a dev work item, and - a delegation (A13) is an episode within one. Neither is a work item of its own. - -### Scope - -- **A scope holds context shared by the work items that operate on it:** for a repo, - the clone and the project's conventions; for infrastructure, the A17 world model - of that site or service. -- **Many work items may share one scope.** A feature waiting on review and a bug fix - in the same repo are two work items on one scope. So are two failures found in the - same log run. -- **A work item has zero or more scopes.** A dev work item has exactly one repo for - now. A general question may have none. -- **Scope identifiers follow existing naming.** Repos use `owner/repo`. Sites use the - site identifier (Part 0), combined with the service. - -### Park-and-switch: one active work item per agent - -- **Each agent has at most one active work item.** Others are *parked*, with their - full position held in the state store (A2). -- **Switching happens only at safe points,** where the loop is waiting on a human - (an open gate, a pending question, a PR in review) or is idle. It never happens - mid-turn. Because turns run to completion, every point at which a command can - arrive is a safe point, with one exception: switching is refused while intents - are unresolved (A2). That keeps every pending intent owned by the active work - item, so boot reconciliation (A3) stays single-item. -- **The operator can always switch; the agent may switch on its own in two cases,** - when other work items are ready: - - *The active item is waiting on the operator* (an open gate, a pending question, - or a halt in `AWAIT_INPUT`). An alert must not sit for hours behind an - unanswered prompt. The item it left stays waiting; switching answers nothing - on its behalf. - - *The active item has closed* (completed or abandoned). Ready work should not - sit behind an idle agent until the operator notices. - - Either way, it posts a notice naming both items and why it chose that one. The - agent never switches away from an item that is working. -- **A grace period comes before switching away from a waiting item.** After the - active item posts a plan or a question, the agent waits a configurable period - (default 10 minutes) before switching. An operator who is watching answers in - that time and the item carries on without a switch. Otherwise their approval - could sit behind a long milestone on the other item: once parked, an item only - becomes active again when the active one waits or closes. Closing an item needs - no grace period. -- **A parked item is ready when the agent can act on it without the operator:** it - has not started yet (a duty finding, a ticket), or intake arrived while it was - parked that answers what it was waiting for (an approval, an answer, a review - comment). An item still waiting on the operator, with nothing new, is not ready. -- **The model chooses which ready item to switch to.** This is one of the few - judgments it is trusted with outright, because choosing grants nothing: every - item still meets its own gates, and a poor choice costs time, not safety. - - *It is an agent-level call,* belonging to no work item (A10), made by the - `executor` role at the current config (A11 pins roles per item, and this call - has none). - - *It sees summaries rendered by code from the state store,* one per ready item: - source, age, severity, current state, title. It never sees another item's - transcript, so work items stay isolated (A10). - - *It falls back to the oldest ready item* if the call fails or its answer does - not name one of them. Switching never waits on a model. - - *The switch is one state change* (A9 `work_item.switch`), naming both items, - the reason, and whether the model, the fallback, or the operator chose. -- **Parking gives up authority.** Parking a work item unmounts any elevated MCP - instances (F3) its approved plan mounted. They are remounted when it becomes - active again, if its approval has not expired (C2). An approved plan's - credentials never serve another item. -- **Nothing is lost while parked.** Intake for a parked work item, such as comments - on its Gitea issue or PR, is read from a durable per-thread cursor when it becomes - active again. It is not dropped, and it is not replayed from the beginning. -- **The state schema must not assume one item.** Work items live in their own table, - and "active" is a pointer to one of them. Allowing more than one active item later - (Part H) is then a control-loop change, not a migration of every stored item. - ---- - -## PART A — THE AGENT LIBRARY (`nisse-core`) - -The shared spine. Every component here is consumed by both agents. This is the part -to get right. - -### A1. Transport - -**Purpose:** all human⇄agent conversation — commands in, questions/approvals/updates -out. - -Requirements: -- A `Transport` interface the orchestrator depends on; **no platform SDK imported - directly** anywhere else. Methods (at least): `send_message`, - `send_approval_request`, `ask_question`, `on_message` (inbound callback/stream). -- **`MatrixTransport`** is the first and primary implementation. Self-hosted - homeserver (Synapse or Conduit) keeps the control plane in-network. Must support - threaded conversations (for plan discussion) and per-agent identities. -- Interface must make adding `TelegramTransport` / `SlackTransport` later a matter of - one new class + config, no orchestrator changes. -- Both agents run as **distinct identities** (`@nisse-dev`, `@nisse-ops`) in separate - rooms on the same homeserver. - -Explicitly *not* required: rich message formatting, custom clients. Existing Matrix -clients (Element) are the UI. - -**Matrix is strictly a UI channel.** Although the homeserver is self-hosted and -therefore not "remote," room history must not become load-bearing context. The -canonical log (A10) is the sole source of truth. - -### A2. State Store and Durability *(critical path — build first)* - -**Purpose:** durable, crash-safe persistence of everything that must outlive a -process restart. This is what makes the whole system safe to redeploy and reboot; it -blocks everything else. - -Must persist: the active work item and each work item's state-machine position -(0.2), approvals granted, pending questions, session continuity, and scope state such -as (dev) git clone locations. - -Disposable (never on the state volume): agent code, build artifacts/test containers, -shipped-out logs. - -Requirements: -- **Crash-atomic writes.** SQLite in **WAL mode** (default choice) or atomic - file writes (`write-temp → fsync → rename`). A power cut mid-write must never - produce torn state. -- **Checkpoint after every state transition**, with `fsync`. No "flush later" — - in-memory state that hasn't hit disk is assumed lost. -- **Durable intent log.** Before a unit of work starts, record intent, so a restart - knows what was in flight. -- **Idempotent transitions reconciled against external truth.** On any operation with - an external side effect (open PR, push branch), restart must detect the effect - already happened (query Gitea/git) rather than repeat it. -- **`schema_version`** field from the first commit, with a migration hook, so a newer - image can read older state. - -### A3. Boot-Time Reconciliation - -**Purpose:** turn an ambiguous crash into a safe, known state. - -Every startup (clean or post-crash), before accepting operator commands: -1. Open state store (WAL recovery is automatic). -2. Run any pending schema migration. -3. Read intent log; look for a transition that began but never committed. -4. Reconcile against external truth (Gitea/git for the dev agent; relevant services - for the sysadmin agent). -5. **Interrupted mid-delegation (A13) — apply the deterministic resume rule.** - **Auto-resume** the delegation — re-render its transcript from the canonical log - and continue the loop, posting a notice (not a question) to transport — when the - cut is clean: the last durable event is a completed `tool_result` or assistant - turn, the delegation branch sits at the expected commit, no cap is breached, and - external truth matches the log. **Gate to `AWAIT_INPUT`** when the last event is a - `tool_call` with no recorded result whose effect cannot be verified by inspection - (tree diff for edits, Gitea query for pushes), or on any branch/log mismatch. The - judgment is an observable check, never model self-assessment — the same reasoning - as A12. -6. **Interrupted mid-sequence (C2) — resume only on evidence.** An approved step - sequence may interrupt itself: rebooting the host the agent runs on is a - legitimate step. On restart, **continue** the sequence only when the intent log - names exactly which step was in flight and that step's expected outcome checks - out against the live system, and the approval has not expired. Anything else - (no recorded outcome check, a failed check, or more than one step - possibly in flight) gates to `AWAIT_INPUT`. The step counts as completed only - by that check, never by assuming it ran. -7. Any other interruption → move to **`AWAIT_INPUT`** and post to transport - (e.g. "restarted mid-milestone-2, branch at commit abc123, continue or re-plan?"). - Never blind-resume. -8. Otherwise resume the last committed state. - -### A4. Control Loop, State Machine, and Gating Primitives - -**Purpose:** the generic engine that drives an agent through gated work. Each agent -supplies its own concrete states; the primitives are shared. - -Requirements: -- A **state machine** framework: named states, transitions, and human gates. Each - gate blocks until an explicit operator action arrives via transport. -- **Gating is structural, not advisory.** The mechanism (used by both agents): - - Expose a **`submit_plan(type, content)`** tool to the model. Calling it posts the - plan for approval and **ends the agent's turn.** The agent cannot self-advance. - - Expose an **`ask_human(question)`** tool — non-terminal: pause, post question, - block until answered, resume. - - **Do not** expose any "proceed" / "start" tool. Advancement past a gate happens - only when the orchestrator receives operator approval and hands over the next - task. The agent has no path to approve itself. -- **Approval sources.** A gate waits on an approval event from any registered - **`ApprovalSource`** — the Matrix transport (A1), the Gitea comment poller (B2), or - the Vikunja comment stream (C4). **The knowledge store (A17) is never an approval - source**, and never an instruction channel: a wiki page has neither the identity - guarantees nor the revision binding an approval requires. First valid approval wins and is consumed; the - gate records which channel satisfied it, and the orchestrator echoes the outcome to - the other channels so the records converge. Each source authenticates the approver - against a per-channel operator allowlist (Matrix MXID, Gitea username, Vikunja - username). -- **Approvals bind to a plan revision, never to "the current gate."** Every submitted - plan revision carries a monotonically-increasing tag (`rev-N`), numbered across - the whole work item rather than per gate, so a tag names exactly one plan. An - approval naming a stale revision is rejected with a notice, never applied to a - newer one — this is what makes multi-channel approval race-free. -- **A reply that names no revision is about the one up when it arrived.** A bare - `approve`, or any feedback, is stamped on arrival with the revision last put up - for sign-off: in Matrix, the one whose approval request had been sent; in Gitea, - the latest plan comment older than the reply. If that is not the current - revision the reply is ambiguous: an approval is not applied, feedback is recorded - in the transcript but rejects nothing, and the agent says which revision is up. - So a repeated `approve`, an approval given on both channels, or an afterthought - sent while one plan was up can never land on the next. (Found by the Quint model, - `specs/orchestrator.qnt`.) -- **One consistent approval UI across all channels.** Approval is always the typed - word `approve`, attached to a specific revision: in Matrix, a reply to the plan - message (the reply relation carries the binding; a bare `approve` in the room binds - to the latest revision only when unambiguous, in the sense above, otherwise the - agent asks; reply relations are not implemented yet); in Gitea - and Vikunja, a comment `approve rev-N`, with the tag printed in the plan comment - for copy-paste. Any other reply/comment is `reject `. Reactions/emoji are - never an approval path. Approvals are consumed at first sight; later edits to an - approval comment are ignored and logged. -- **Approvals bind to a work item, too (0.2).** A bare `approve` binds to the - *active* work item's latest revision, under the unambiguity rule above. An - approval arriving on a *parked* item's external record (a Gitea or Vikunja - comment) is delivered when that item next becomes active. So is a Matrix reply to - a parked item's plan or prompt: the reply relation binds it to that item. Neither - is ever applied to the active item. -- **Autonomy inside an approved unit of work:** all tool/command use auto-approved - (Claude Agent SDK `bypassPermissions` or an allow-all `canUseTool`). Escalation to - the human happens *only* via `ask_human`. For the Sysadmin Agent, "approved" - means the step list of C2. Only calls that match an approved step are - auto-approved. -- **Kill switch.** The operator command `stop` works in every state, for every - agent. It ends the current turn or delegation at the next tool boundary, unmounts - elevated MCP instances (F3), and moves to `AWAIT_INPUT`. The unfinished delegation - or step sequence is then handled like any other halt: continue, re-plan, or - abandon, on the operator's word. Unattended work needs a way to end it that does - not depend on the model cooperating. -- The loop must survive restart mid-work (relies on A2/A3). - -### A5. LLM Access *(tiered)* - -**Purpose:** reach both a locally-hosted model and frontier models through one -interface, with the frontier vendor swappable by config. - -A locally-hosted model drives the control loop and performs mechanical work; frontier -models are engaged for work that exceeds local capability. Two requirements drive the -tiering: - -1. **No remotely-held state.** Conversation and task state lives on the agent VM. No - provider holds anything the system depends on to resume. -2. **Vendor fluidity.** The frontier tier should be swappable — Anthropic, OpenAI, - Google, or a vendor that does not exist yet — as a configuration change, not a - refactor. - -These two are only in tension if portability is required *within* a session. Making -frontier engagements **ephemeral and self-contained** (A13) allows vendor-specific -session data to be used freely inside a delegation while keeping the durable record -vendor-neutral. - -Requirements: -- **One client interface**, `complete(canonical_log, tools, model) -> canonical_events`, - present at the **first real LLM call** — not introduced as a later cutover. The same - reasoning that produced the `Transport` abstraction. -- **LiteLLM in front of everything, from the start.** The agent VM gets exactly one - LLM destination; LiteLLM fans out to the local inference host and to frontier - vendors behind it. This also resolves the RFC1918 conflict in B3 — a LAN-resident - inference host would otherwise need a second hole punched in the egress allowlist. -- **Passthrough for the vendor driving a given session** (`/v1/messages` for - Anthropic), so vendor-specific features are not lost on the path where they matter — - delegations (A13) always take this path. **Normalized routing for everything else**; - the canonical log (A10) reconciles the difference. -- **Fallback chains configured at LiteLLM.** Rate limits and outages differ per - vendor; a frontier failure should not kill a task. -- **No vendor credentials on the agent VM.** LiteLLM holds every key, sourced from - Bitwarden machine accounts (A7); the agent holds one LiteLLM token. Adding a vendor - does not touch the agent VM, and a compromised agent leaks no provider keys. -- Config from the agent's config file, **nothing hardcoded**: base URL, auth token, - and the role mapping (A11). Secrets in it are references to environment - variables, never values. Changing any of it is a config edit + restart, - nothing more. -- **Budget caps and spend alerts live on the LiteLLM virtual key**, not in agent code. - -### A6. Browser Tool - -**Purpose:** drive web UIs. Shared implementation; each agent points it at a -different network with different trust. - -Requirements: -- **Playwright** (headless Chromium), exposed to the model as tool actions: - navigate, click, type, read DOM, screenshot. Screenshot + accessibility-tree loop - so the model can operate UIs it wasn't trained on. -- Runs **inside the agent's VM**. Network reachability is set by the agent's - deployment, not the tool. -- Must support authenticated sessions (for the sysadmin agent's admin UIs) while - being usable without auth (dev agent's test apps). - -### A7. Secrets Client - -**Purpose:** fetch credentials at runtime instead of baking them into images. - -Requirements: -- Client for **Bitwarden/Vaultwarden** via **machine account / Secrets Manager** (or - `bw` CLI + API key). Not the personal vault. -- Reads only from a **dedicated, minimally-scoped machine account** per agent, **plus - one per MCP server instance** (F3). Each (service, site, privilege) instance holds - its own upstream credential, so it gets its own machine account, and the agent's - account cannot read an instance's credential. -- Each machine account has a single **bootstrap token**. These are the only secrets - that live on the box: tight file perms, never in image or repo. Each token is - available only to its own container. -- (Deferred, not first cut: dynamic/short-lived secrets via Vault/OpenBao. Leave the - interface able to grow toward this; don't build it yet.) - -### A8. SSO Identity - -**Purpose:** each agent authenticates as itself. - -Requirements: -- Each agent is a dedicated **LLDAP** principal (`svc-nisse-dev`, `svc-nisse-ops`) in - a tightly-scoped **Authelia** group. Never the operator's account. -- Support an **unattended-auth path**: because interactive MFA is impossible for a - headless agent, allow a narrow **IP-scoped, group-scoped, target-scoped** - single-factor exception. Keep it specific on all three axes. - -### A9. Logging - -**Purpose:** observability without bloating state. - -Requirements: structured logs to journald / external log stack, **not** kept -in-container. Every gate crossing, tool call, and escalation is logged. - -**State-change log conventions.** Every durable mutation of agent state emits -exactly one structured log event, so problems can be diagnosed after the fact -by replaying the operational trail: - -- **Emission is owned by the state store.** The log line is written by the same - code path that performs the durable write, immediately after the commit - returns — a state-change line exists iff the change hit disk. Callers supply - the *cause*; no other layer logs durable mutations, so the trail cannot drift - from what was persisted. -- **Format: one JSON object per line**, with a fixed envelope on every event: - - `ts` — UTC ISO-8601 timestamp - - `agent` — `nisse-dev` | `nisse-ops` - - `event` — dotted type from the taxonomy below - - `task_id` — the enclosing work item's ID (0.2), minted when the work item - opens (`use `, ticket pickup, an operator question, a duty's finding) - and threaded through every event it causes; `null` only outside any work item - (e.g. boot, a duty run before it yields a finding, or choosing which item to - switch to). A milestone is a phase - within a work item, so starting one does not mint a new ID. - - `cause` — what triggered the mutation: operator command, approval - (`rev-N` + channel), escalation trigger, reconciliation verdict, or crash - recovery. -- **Event taxonomy** (extended as components are built; names are never reused - or renamed): - - | Event | Event-specific fields | - |---|---| - | `state.transition` | `from`, `to`, `project` | - | `intent.begin` / `intent.commit` / `intent.abort` | `intent_id`, `kind` | - | `work_item.switch` | `from` (parked or closed item, or `null`), `to`, `reason`, `chosen_by` (`model` / `fallback` / `operator`) | - | `gate.open` / `gate.approve` / `gate.reject` | `gate`, `rev`, `channel` | - | `boot.reconcile` | `action`, per-intent verdicts | - | `schema.migrate` | `from_version`, `to_version` | - -- **Logs are diagnostic, never load-bearing.** Nothing reads logs back to - derive state — the state store (A2) is the sole source of truth, the same - rule that keeps Matrix room history (A1) non-authoritative. - -### A10. Canonical Transcript Schema - -The durable record is a **canonical event log** in SQLite, not a provider-shaped -`messages` array. Provider request bodies are rendered at call time from this log. - -| Event | Payload | -|---|---| -| `user_message` | text, source (Matrix event ID) | -| `assistant_text` | text, model that produced it | -| `tool_call` | canonical call ID, tool name, arguments | -| `tool_result` | canonical call ID, result, success/failure | -| `plan_submitted` | plan body, authoring model | -| `gate_opened` | gate type, reason | -| `delegation_started` | brief, target role, resolved model, caps | -| `delegation_completed` | handback note, artifacts, tokens, duration, exit status | -| `compaction` | summary, producing model, range of events replaced | -| `knowledge_retrieved` | document text as read, document ID, revision, retrieved-at (A17) | - -Rules: -- **The log is scoped to a work item (0.2).** Every event carries its work item's ID, - and a model call for one work item renders only that item's events. Evaluation - capture (A16) may read across items. -- **Some calls belong to no work item,** and their events go to an **agent-level - scope**, with no work item ID: a duty run until it yields a finding, and the - choice of which item to switch to (0.2). Each such call renders only its own - events plus what code gives it. Agent-level events are never rendered into a - work item's call, and a work item's events reach an agent-level call only as a - code-rendered summary. -- **Canonical tool-call IDs are minted locally.** Provider IDs are mapped at the - boundary and never used as a join key. Providers differ in both ID format and in - where tool calls sit structurally (content blocks vs. a separate array on the - assistant message). -- **No provider-side handles for content.** If a Files API is used, store the bytes or - a content-addressed local path; upload or inline at render time. A provider file ID - must never be the only reference to something. -- **Vendor-opaque reasoning artifacts are stored, tagged with the producing model, and - never replayed outside the session that produced them.** This covers Anthropic - signed thinking blocks, OpenAI encrypted reasoning items, and Gemini thought - signatures. Inside a delegation, replay per that vendor's rules. On delegation exit, - the artifacts become archival only. A "session" here is the **delegation**, not the - process lifetime: a delegation resumed after a crash (A3) is the same session and - may replay its own artifacts. -- **Token budgeting is a render-time concern.** Context window and tokenizer are - properties of the model being called. Compute budget when building the request, not - when writing the log. -- **Compaction summaries are first-class log events** with the producing model - recorded, so a tier change does not silently inherit opaquely-compacted history. -- **Prompt cache breakpoints are placed by the renderer and never stored.** A tier - change means a cold cache — a cost event, not a correctness event. - -### A11. Role Configuration - -Model identity is expressed as **roles**, resolved to concrete model strings in config: - -```yaml -roles: - executor: local/ # drives the loop, mechanical work - planner: # authors plans - delegate: # scoped sub-task execution - consultant: # advisory, no repo access -``` - -"Best model this week" is a config edit. **The resolved mapping is pinned at task -start** and recorded in the log, so a mid-task config change cannot split a task -across two planners. - -### A12. Escalation Triggers - -The local model does **not** decide when it needs help. Small models are poorly -calibrated about their own competence and will escalate either constantly or never. -Triggers are observable from outside the model: - -1. **Phase policy.** Planning and architecture escalate unconditionally. Mechanical - edits stay local. -2. **Failure count.** Local attempts; tests fail twice; escalate with the failures - attached to the brief. - -A model-invoked `ask_expert` (A14) exists in addition to these, never as a -replacement, and carries a per-task budget so a confused executor cannot spend twenty -consults circling. - -### A13. Delegation - -Escalation is **delegation**: a frontier model receives a scoped sub-task, works -autonomously with full tool access, and hands back. It is not a consult, and the local -model does not interpret or re-derive the frontier model's reasoning. - -**Harness — one, not one per vendor.** Do not adopt each vendor's agent SDK to obtain -their best agentic behavior; that recreates the lock-in this design avoids, relocated. -One loop, one cap regime, one handback contract. - -**Normalization lives in the A10 renderer, not on the wire.** Vendors disagree -structurally about tool calling (content blocks vs. `tool_calls` arrays, tool-result -placement, streaming deltas) and about reasoning artifacts (signed thinking blocks, -encrypted reasoning items, thought signatures) — and a normalizing middlebox is lossy -exactly where a long-running delegation lives. So: - -- **Delegations run over LiteLLM's passthrough route** (`/v1/messages` for Anthropic, - per A5), with the A10 renderer producing the vendor-native request body. The - renderer is a per-vendor adapter by definition — it already has to know each wire - shape to render the canonical log. -- **LiteLLM's normalized route is reserved for calls where fidelity is cheap:** - `ask_expert` (A14), fallback chains, and trialing a new vendor before its renderer - adapter exists. -- **Adding a vendor** therefore means writing one renderer adapter — or provisionally - accepting LiteLLM's normalization for that vendor and validating it against the A16 - eval set before trusting it with delegations. A day-one build needs exactly one - adapter: the vendor actually in use. - -**Scoping and authority.** Delegation sits **inside** the plan gate, not around it. A -frontier sub-agent that acts freely would bypass `submit_plan` (A4), which is the -structural control the design rests on. - -- **Planning is itself a delegation** that produces a plan and stops. Human approves. -- **Execution delegations are scoped to a sub-task the approved plan already - contains.** - -The frontier model is smart twice; neither time is it authorized to expand the work. - -Because plans may be executed by a materially weaker model, the planner system prompt -must require **explicit, low-inference plans** — the executor cannot reconstruct -intent. - -**Isolation and caps.** -- **Branch at delegation start.** Abort discards the branch. This gives delegations the - same atomicity property that boot-time reconciliation (A3) gives interrupted state - transitions. -- **Hard caps on turns, tokens, and wall clock.** -- **Checkpoint delegation events as they arrive.** Every `tool_call` / `tool_result` / - assistant turn inside a delegation is durably written to the canonical log when it - happens (per A2), never batched at handback. This is what makes a delegation - resumable after a crash — the deterministic resume rule lives in A3. -- **Defined failure return.** On cap breach or explicit failure, the local loop gates - to `AWAIT_INPUT` rather than resuming on top of a half-edited tree. - -**Handback.** The final delegation action is a **structured handback note written for a -weaker reader**, not a human-facing summary: - -- what changed -- what is now true that was not before -- what to do next - -That note plus the resulting file state is the entire interface back to the local loop. -The delegation's internal transcript is stored alongside the composite event, -vendor-tagged, and never replayed into anything. - -#### Rejected alternative: ask-and-apply - -An alternative was considered in which the local model asks a frontier model for advice -and applies the answer itself. **Rejected.** - -Its principal advantage was blast radius — the frontier model never touches the repo, -and everything flows through a gated local loop. That was explicitly not a concern -here. - -Without it, the comparison is lopsided: - -- Delegation lets the frontier model read the code, run the tests, and see the - failures. Ask-and-apply forces it to reason blind from whatever the local model - thought to include in the brief, and small models write poor briefs. -- The cost advantage is illusory: one delegation that succeeds beats three consult - round-trips that do not. -- Any variant where the local model *interprets* rather than applies verbatim - reintroduces the capability gap downstream of the expensive call. - -The diff-application machinery, verbatim-fidelity rules, and local-model-as-verifier -design are all dropped. - -### A14. `ask_expert` (retained, minimal) - -One narrow case survives delegation: **questions requiring no repo access.** "Which of -these two approaches is better," "why does this library behave this way," "is this API -deprecated." - -- Single stateless call, text answer. -- No diff application, no fidelity concerns, no session state. -- Reuses the delegation brief format. -- Structured output contracted **at the prompt level, not the API level** — vendors - differ on JSON mode and schema enforcement. Parse, validate against a local schema, - retry once. - -Expected implementation size: small. Do not let it grow into a second escalation -subsystem. - -### A15. Local Model Accommodations - -- **Constrained decoding for tool-call JSON.** Requested with `tool_choice` - (`required`), and only of models whose LiteLLM entry declares - `supports_tool_choice`: enforcement is the model server's, and asking one that - can't honour it can be worse than not asking. Where it isn't available, the - loop enforces the contract itself: an explicit `finish` tool ends the work, a - prose reply is a violation, and a call to a tool not offered counts as a - failure (A12). -- **Reduced tool schema for the executor role:** fewer tools, flatter arguments, no - deeply nested optionals. Maintain **two tool manifests keyed on role**, rendered at - request time like everything else. - -### A16. Delegation Evaluation Capture - -Capture a **consult/delegation eval set** from day one. Each `delegation_started` / -`delegation_completed` pair (A10) is already a clean input-output record; the -additional cost is marking outcomes as good or bad. - -Without this, "best model this week" is decided by benchmark chatter, and swapping the -frontier tier becomes something to avoid because the effect is unmeasurable. With it, a -new model is a config change and a re-run. -### A17. Knowledge Store (Outline) - -**Purpose:** durable knowledge that outlives a single task — an agent-maintained -**world model** (what infrastructure exists, how it is arranged) and, later, -operator-authored **procedures**. Distinct from A2, which holds *task* state: A2 -is the agent's memory of what it is doing, A17 is its memory of what the world -looks like. Neither is a fallback for the other. - -Part D describes the service; this defines the contract. - -**Layout — sharing is read access, never write access.** -- Every document has **exactly one writer**: one agent, or the operator. Sharing - governs who may *read*. Concurrent writers therefore never exist, which - disposes of the last-write-wins objection Part D raises, without needing - distributed-write machinery. -- **Agent-private** space per agent: its own world model. -- **Shared** space: readable by the operator and designated agents. Default - membership is **operator + `nisse-ops`**. `nisse-dev` gets private space only — - B3 seals it from RFC1918 precisely so it cannot reach the NAS, Proxmox, or the - UDM, and a shared network-architecture document is a map of exactly those - things. Cross-agent read access is justified per collection, never assumed. -- The dev agent's project conventions live in the **repo it is working on**, not - here: versioned with the code, reviewed in the PR, and impossible to - desynchronise from the project. - -**Agent-owned knowledge is always a cache, never authority.** An agent may rely on -it to know *what exists and where to look*. It must not rely on it for the facts an -action depends on. A prompt cannot enforce that: the model cannot be prevented from -*believing* a fact it has read. The enforcement is elsewhere, and it is structural: -- **Step preconditions (C2) carry the weight.** Every elevated write is an approved - step, and a step that depends on a fact states it as a precondition, checked - against the live system immediately before the step runs. A stale world model - can produce a wrong plan, but it cannot turn that plan into an action on a - system that no longer matches it. -- **Provenance at the human gate.** When a plan is drafted from recalled facts, the - approval message says so: "target recalled from the world model, observed - 2026-08-02" versus "confirmed by `zfs list` two minutes ago". The operator is - the authority there; this makes the evidence quality visible. -- **Audit for the rest.** Retrievals are logged (A10), so a wrong fact can be traced - to the actions that followed it. - -**Dropped:** withholding facts past a staleness bound at retrieval, and refusing -confirm-first actions without a verifying observation in the log. Both were -approximations of what step preconditions (C2) now check directly. - -**Never an instruction channel, and never an approval channel.** -- **Task instructions never come from the knowledge store.** Work is driven by - Vikunja tickets (C4) and operator commands (B2). An agent must never read the - store looking for work to do. -- **Approvals never come from the knowledge store.** The registered - `ApprovalSource`s are Matrix (A1), Gitea (B2), and Vikunja (C4). A wiki page is - not one and must not become one. -- An agent must never write here intending to cause another agent to act. This - extends Part D's rejection of the store as a message bus: the erosion path is - one well-meaning "note for the other agent" at a time. - -**Corrections flow through transport, not through direct edits.** The operator -corrects a fact by telling the agent, which is strictly better than editing the -page by hand: a hand edit is invisible to the agent's record, whereas an -instruction arrives on an authenticated channel from an allowlisted identity, is -recorded in the canonical log, and can be reconciled against what the agent -believed. Corrections are stored **verbatim with attribution**, never paraphrased — -a model rewriting a precise fact loses precision invisibly. -- **Break-glass:** the operator keeps write permission. The procedure reuses Part - F's idle flag — wait for idle, stop the agent, edit freely, restart. Single - writer holds trivially while the agent is down, and A2/A3 make stopping safe. -- Because that permission exists, an agent re-reads and compares the revision - immediately before writing, and re-merges rather than clobbering if it moved. - A safety net for the rare edit, not a designed workflow. - -**Canonical log representation (A10).** Retrieved content is **snapshotted into the -log** — the text, plus document id and revision for provenance — never referenced -by handle alone: A10 forbids a provider handle being the only reference to content, -and these documents are mutable. Snapshot once per task at first use, not per turn. -A task resumed across a restart carries the old snapshot; step preconditions, not -a re-fetch, are what stop it acting on a stale copy. - -**Retrieval is structural first, discretionary second** — the A12/A14 pattern. -- Procedures are **bound to entries in the C2 policy file** (a tool, or a protected - target), and fetched when a plan that uses that entry is drafted. Binding is - deterministic and auditable ("which procedure governed this action?" has an - exact answer), and costs little because the policy file must exist anyway. -- A model-invoked **search** exists *in addition* for discovery, never as a - replacement for the bound retrieval — a procedure the model may skip does not - constrain it. - -**Outage posture is asymmetric.** Unlike B2's poller, "never fails a task" does not -generalise here. -- **Write failure never blocks:** retry, or record locally and reconcile. -- **Read failure for a bound procedure gates the plan.** Planning without it is not - a delayed reaction; it is acting unconstrained on precisely the operation someone - wrote a procedure for. -- Read failure for anything else proceeds, logging the gap. - -**Accepted single point of failure.** Once the agent is the sole author and Outline -the only home, losing Outline loses the world model. No local mirror: infrastructure -facts are re-derivable by observation, which is the same premise the cache rule -rests on. - -**Deferred:** the operator-writable procedures collection. The interface is defined -here; the content arrives when there are procedures worth writing, and binds to the -C2 policy file. - -**Concrete mapping onto Outline.** Observed in the running instance: a **collection** -carries per-user permissions with three tiers — visible-to (read), read-write, and -manager. Permissions are collection-scoped, not per-document. -- Therefore **one collection per writing principal**, with read membership granted to - everyone else who needs it: `nisse-ops-world` (write: `nisse-ops`), - `nisse-dev-world` (write: `nisse-dev`), `procedures` (write: operator). -- **What this enforces, precisely:** no *other agent* can write a given agent's world - model — the collision that would otherwise be silent and unattributable. It does not - enforce operator-versus-agent exclusion, because the operator holds manager on their - own wiki and can always grant themselves write. That half is procedural: the - break-glass routine stops the agents first, so concurrent writers do not exist while - editing. -- **Assign per user, not per group.** Outline groups appear to be internal to Outline - rather than federated with LLDAP (A8), so using them would put membership in a second - place that nothing syncs. With three principals the manual mapping is trivial and - obvious; revisit only if the cast grows. -- **Each agent's API token must belong to that agent's own Outline user.** Outline API - keys are per-user, so the token *is* the identity every permission above evaluates - against. A token minted from the operator's account — or an admin's — silently voids - the entire boundary while appearing to work. -- **Identify collections and documents by ID, from config and state.** Collection IDs - come from env (`NISSE_OUTLINE_*_COLLECTION`), never discovered from "whatever the - token can write": a permission change must not silently redefine what an agent - considers its own. Document IDs are recorded in the state store (A2) when created and - used for updates, which also makes writing idempotent — a retry after a crash updates - the document instead of creating a second one. -- **Revision reads, not conditional writes.** Because break-glass stops the agents, - compare-and-swap is unnecessary; what is needed is the ability to *observe* that a - document changed since the agent last read it, feeding the A3 divergence check. - Whether Outline exposes a revision identifier or only a modified timestamp is - unverified — either satisfies this. - -#### Considered and deferred: Outline's MCP server - -Outline can expose search, read, create and edit over MCP (remote, Streamable HTTP, -enabled workspace-wide). **Not adopted for the retrieval path**, because A17's -contract depends on this library sitting *between* the model and the store: -snapshotting into the canonical log, binding procedures to C2 policy entries, and -gating a failed bound read all happen to a retrieval before the model sees it. -MCP's purpose is the opposite — direct model access — and a procedure the model may -decline to fetch does not constrain it (A12). - -It remains a reasonable candidate for the *discretionary* search tool later, and unlike -the vendor agent SDKs A13 rejects, MCP is a neutral standard rather than lock-in: one -client would serve many servers if others are ever added. Before adopting it, verify -that it honours the key owner's **collection** permissions rather than mere workspace -membership, and that the workspace-level toggle does not force broader exposure than -intended. - - -### A18. Prompt Injection - -**Assume any model that reads untrusted content may be compromised.** There is no -robust way to tell a model "this is data, not instruction". Instructions and data -reach it as one token stream, and every marker meant to separate them is text that -the data can imitate or argue against. Role separation, delimiters and detection -classifiers lower the success rate of injection. None of them bring it to zero, and -an attacker gets to try repeatedly. So the design does not depend on the model -telling them apart. It limits what a compromised model can do. - -Untrusted content, for these agents: alert payloads, log lines, ticket and comment -text, web pages the browser reads, repository contents, and downloaded packages. - -Requirements: -- **Gates hold against a compromised model.** Nothing a model says can approve a - plan (A4), widen what a plan authorizes (C2), or reach a credential that was not - mounted (F3). Every one of those rules is enforced in code, outside the model. -- **Approval messages are rendered by code from what will actually run** (C2), - never from a model's description of it. Otherwise a compromised model can write - "restart Gitea" above a step that destroys a dataset. -- **Instruction channels are operator-scoped.** Only allowlisted identities can - drive work: operator commands (A1), PR review comments (B2), action-triggering - tickets (C4). Content from anyone else is data. -- **Parse intake into structured fields in code** wherever the source allows it, - e.g. alert name, severity, host, from Alertmanager's JSON. Less free text then - reaches the model at all, and what reaches it is labelled by where it came from. -- **Some injection is accepted because it can only reorder work.** An alert title - can tell the switch decision (0.2) to pick it first. The worst case is ordering, - and the switch notice makes it visible. -- **Check each agent against the lethal trifecta:** access to private data, exposure - to untrusted content, and a channel to send data out. Damage needs all three. - - `nisse-dev` reads untrusted content and holds little private data: its Gitea - token and the repos it works on. Its only ways out are Gitea and package - registries (B3). The registries are an accepted, narrow residual channel. - Routing them through a local mirror would close it, if that ever becomes worth - doing. - - `nisse-ops` has all three: it reads alerts and logs, holds infrastructure - credentials, and has broad egress. It relies on the gates above, not on - removing a leg. - ---- - -## PART B — THE DEV AGENT / `nisse-dev` (build first) - -A thin app over `nisse-core`. Plans and implements software projects; produces PRs. - -In 0.2's terms, a **project** below is a dev work item on a repo scope. `use -` opens or resumes a work item on a repo, and `project complete` closes the -work item. The repo, its clone, and any other work items on it continue. - -### B1. Workflow (state machine) - -``` -IDLE - → [use ] clone repo, begin architecture planning -PLANNING_ARCH - → agent drafts architecture + implementation plan → submit_plan -AWAIT_ARCH_SIGNOFF ← human gate (approve / reject+feedback) - → -MILESTONE_PLANNING - → agent drafts plan for a specific milestone → submit_plan -AWAIT_MILESTONE_SIGNOFF ← human gate - → [start milestone ] -EXECUTING autonomous; no per-command approval - → may enter AWAIT_INPUT (ask_human) any time it's unsure -PR_OPEN - → open PR; react to review comments and commits (review loop) - → on acceptance → next milestone, or project complete - -any waiting state (a sign-off gate, AWAIT_INPUT, PR_OPEN) - → [abandon] close the plan issue and any open PR, → IDLE -``` - -### B2. Requirements - -- **One active project at a time** (park-and-switch, 0.2). The operator selects it, - or the agent switches on its own under 0.2's rules; other projects stay parked at - their last position and resume where they left off. -- **Operator commands** (over transport): `use `, `approve`, - `reject `, `start milestone `, `status`, `stop` (A4), `abandon`, - plus free text during planning (forwarded to the agent as answers). - - `abandon` ends the current work item from any waiting state: the "abandon" - among A4's continue / re-plan / abandon, and a close in 0.2's terms. It closes - the plan issue and any open milestone PR with a note, keeps the branches, and - marks any unresolved intent aborted so boot reconciliation does not gate on - work dropped on purpose. While work is running, `stop` comes first. Like - `status`, it is a command in every state, never read as an answer or feedback. -- **Plan gating** per A4: architecture plan and each milestone plan require sign-off - before the next phase. Enforced structurally. Plans and approvals are externalized - to Gitea issue comments (Part H, decided); Matrix and Gitea are both approval - channels, with revision binding and the uniform `approve` UI per A4. -- **Autonomous execution** within a started milestone: builds, runs, tests without - per-command approval; asks questions any time via `ask_human`. -- **Gitea integration:** - - *Outbound:* `git` + Gitea API to push branches and open PRs, via a dedicated - Gitea **service account** with a scoped token. Record any runnable test service's - URL/port in the PR description. - - **The project repo's default branch must be protected, and this is what makes - the PR gate real.** The service account needs write access to push branches — - and in Gitea, *write access includes merging pull requests*. Repository - ownership does not help: an account that merely collaborates can still merge, - and one that owns the repo can merge and delete it. So without branch - protection the agent can merge its own PR, and the review gate holds only - because the agent's code does not call the merge endpoint. That is precisely - the advisory guarantee A4 exists to rule out. - - Required on the default branch: **block direct pushes** by the agent - account, and **restrict merging** to the operator (a merge whitelist, or - required approvals that the agent's own account cannot satisfy). - - Feature-branch pushes are unaffected, so the agent's normal workflow is - unchanged. - - **Verify it rather than assume it.** The property is observable from - outside: the agent's own token must be refused when it attempts to merge - its own PR and when it pushes to the default branch. Assert both, so that - relaxing the protection later fails a test instead of silently removing - the gate. (Discovered the hard way — before protection was configured, the - agent's token merged its own PR successfully.) - - *Inbound:* react to PR comments, commits, pushes. **Poll** the Gitea API - (15–30s) rather than take webhooks, to keep the VM outbound-only. Intake is - structured JSON — never via a chat bridge. - - *On Gitea unavailability:* the poller backs off (exponential, capped at a few - minutes) and retries indefinitely, logging the outage. Polling is pure intake — - an outage delays reactions but never fails a task or forces a state transition. - Outbound Gitea operations that fail mid-flight are already covered by the - intent log and boot-time reconciliation (A2/A3). -- **Browser (A6)** scoped to the **internal test network only** — used to exercise - web apps it built and reproduce bugs. **No SSO needed** for this. - -### B3. Deployment & Isolation - -- **Dedicated VM** (Proxmox), as for every agent (F1). The Dev Agent has an - additional reason: it builds and runs Docker containers, so it needs its own real - Docker daemon. (Docker-in-a-container via socket-mount ≈ host root; via dind = - nested-storage pain. The VM boundary is the correct trust boundary for unattended - arbitrary command execution.) -- **Sealed to start:** `nisse-dev`'s egress allowlist (F2) permits LiteLLM, Gitea, - and package registries, and there is **no ingress**. Egress denial must include - **RFC1918 except the specific LiteLLM/Gitea IPs**, and also the tailnet's - **`100.64.0.0/10`**, which is not RFC1918. Sealed from the internet must also mean - no reach to NAS/Proxmox/UDM. - - **The LAN-resident inference host is not a second exception.** It sits behind - LiteLLM (A5), so the allowlist keeps exactly one LLM destination regardless of how - many models or vendors are in play. -- **No Tailscale** on this VM (it routes around firewall policy). Manage via Proxmox - console / jump host. -- **Own VLAN from day one** (even a VLAN of one) and test services on **predictable - ports** (e.g. 18000–18099), so opening access later is a firewall edit, not a - rebuild. (Sealed for now; may relax later.) -- Test containers on a dedicated **internal Docker network with no route out**. -- Build-daemon storage on a **separate mount** from the state volume, so - `docker system prune` between milestones can't touch agent state. - ---- - -## PART C — THE SYSADMIN AGENT / `nisse-ops` (follow-on) - -A thin app over the extracted `nisse-core`. Operates infrastructure; driven by -tickets; gated on consequences. Built after the Dev Agent works. - -### C1. How it differs (not reducible to config) - -| Axis | Dev Agent | Sysadmin Agent | -|---|---|---| -| Rollback | revert snapshot / discard branch | none for `zfs destroy` or a firewall lockout | -| Trust model | allow-all in sandbox | **approved step sequences**, checked on every call | -| Work shape | long milestones → a PR | short reactive tasks + scheduled checks → live changes | -| Driver | Gitea projects | **Vikunja tickets** | -| Placement | sealed, own VLAN, no Tailscale | Tailscale, credentials, broad reach | - -Running both roles in one instance would collapse isolation — build them as separate -deployments over the same library. - -### C2. Permission Policy Engine *(the defining addition)* - -**Every write requires an approved plan first.** Write credentials exist only in the -elevated MCP server instances (F3), and those are mounted only after plan approval. -Read-only telemetry (metrics, logs, status) goes through the read-only instances, -which are mounted by default, so it needs no plan. - -This is the sysadmin analog of the dev agent's plan gate. The dev agent has a second -gate after the work (the PR); the sysadmin agent does not, because a live change has -no review step before it takes effect. So its plan gate has to be exact. - -**An ops plan is a list of literal steps, and approval covers exactly those steps.** -A write plan is submitted through `submit_plan` (A4) as structured data, not prose. -Each step names: -- the **tool and target**, e.g. the elevated Proxmox instance for `garrett-hall`, - node and VM ID; -- the **arguments**, in full. A config change carries the exact diff; -- an optional **precondition**, checked against the live system just before the - step runs; -- an optional **expected outcome**, checked just after, e.g. host reachable again, - config hash matches; -- a **retry allowance**, default one. - -One approval can cover a sequence with several high-consequence steps: "reboot the -host, change the config, reboot again" is one plan and one `approve`. The plan may -also carry prose explaining it, which is context for the operator, never scope. - -**The approval message is rendered by code, from the steps.** What the operator reads -is what will run; a model's description of a step is never the thing approved (see -A18). Steps that are irreversible, that touch a protected target, or that will take -down the agent's own host are flagged at the top of the message. - -**Execution is checked against the approved steps.** Every tool call passes through a -matcher before it reaches an MCP instance: -- **A read-only call is always allowed.** The agent can look around between steps. -- **An elevated call runs only if it exactly matches the next unfinished approved - step.** Arguments are normalized against the tool's JSON Schema before comparison - (types, key order), so a model's formatting cannot cause a false mismatch. -- **Anything else halts:** a call not in the plan, different arguments, steps out of - order, or a step run again after it completed. The agent moves to `AWAIT_INPUT`, - and the operator decides whether to continue, re-plan, or abandon. -- **A failed step may be retried** up to its allowance. A failed precondition or - expected-outcome check halts, without retry: the world does not match the plan, - and improvising around that is exactly what the gate exists to prevent. -- **A step that may have interrupted the agent** (a reboot of its own host) resumes - under A3's sequence rule. - -**Approvals expire.** An approved plan that has not started within the expiry window -must be approved again. An approval is a judgment about the world as it was when the -operator read the plan. The window is policy data (Part H). - -**Classification is small, conservative, and data.** The policy is a declared, -versioned file, like the egress allowlist (F2). It holds: -- **Instance privilege.** Read-only or elevated, per MCP instance (F3). This alone - decides whether a call needs an approved step. -- **Protected targets.** The NAS, the gateways, the hosts the agents run on, and - anything else whose loss is expensive. Protection changes how a step is - presented, not whether it is allowed. -- **Each elevated tool's target argument** (`vmid`, `node`, a device MAC), so the - matcher and the renderer can tell which target a call touches. An elevated tool - with no mapping is treated as touching a protected target. - -Defaults fail safe. Anything unclassified is treated as elevated. MCP tool annotations -(`readOnlyHint`, `destructiveHint`) are declared by the server itself, so they may -make a tool stricter, never looser. A model may propose entries for a new server's -tools offline, as a PR the operator reviews. Nothing classifies at run time. - -**Deferred, with the reason:** -- **Bounded matching** for reversible tools: allowing any call within an approved - target and action, not only the exact arguments. Added one tool at a time, where - exact matching causes real friction. It needs a per-tool reversibility - classification, which is the up-front cost exact matching avoids. -- **Standing approvals:** a plan template with a trigger, a rate limit and an - expiry, e.g. "restart Gitea on sustained 502, at most twice an hour, notify - after". The same matcher, run without a fresh `approve`. Added when a routine fix - has been approved by hand often enough to show its shape. - -Special cases: -- **Browser actions against admin UIs cannot be matched as steps,** since a click is - not a stable literal. Each one is confirm-first over transport, even within an - approved plan. Changes to **network gear (UniFi) are the most conservatively - gated action in the system**: a bad remote UI click can sever the path used to fix - it. -- **Action-triggering tickets are operator-scoped** (C4). Auto-generated tickets - (e.g. from a monitoring alert) can only lead to a plan, which the operator - approves like any other. They never drive unattended. - -### C3. Interfaces - -- **MCP servers (F3):** Proxmox and UniFi, at both sites, through per-site, - per-privilege server instances on the agent's own Docker host. This is the primary - path to that infrastructure. -- **Browser (A6):** into admin UIs. **Prefer real APIs where they exist** (Proxmox, - TrueNAS good; UniFi now reachable over MCP); use the browser only for genuinely - UI-only gaps. It's the least stable interface. For network gear, it is a fallback - for API gaps, not the primary path. -- **SSO (A8):** logs into admin UIs as `svc-nisse-ops`. This account is a high-value - credential; its blast radius equals its Authelia scope, so scope it minimally. -- **Secrets (A7):** UI/SSO logins and service API tokens from Bitwarden. -- **Event intake:** monitoring feeds the orchestrator as **structured JSON** - (directly or via Alertmanager), never through a Matrix bridge. The agent posts - curated, state-aware updates to `#ops` ("Gitea 502ing, restarted, back up"). - -### C4. Ticket-Driven Control (Vikunja) - -The sysadmin analog of "dev agent driven by Gitea." -- **Vikunja ticket = task of record** (durable, has status/queue/history): the - external record of a work item (0.2). **Matrix = live conversation about it** - (questions, halts, progress). -- **Scheduled checks and alert intake are duties (0.2).** Tickets they generate are - duty findings, deduplicated against open tickets, and like any auto-generated - ticket they can only lead to a plan the operator approves (C2). -- **Flow:** ticket filed → agent picks up → discuss/confirm over Matrix → update - status as it progresses → close with result. -- **Mechanics:** Vikunja REST API + webhooks (same two-direction, JSON-only pattern - as Gitea); dedicated service account, scoped token from Bitwarden. Verify the - installed version's webhooks fire on comment creation; if not, poll comments as - B2 polls Gitea. -- **Approvals:** Vikunja is an `ApprovalSource` (A4) for plan gates on its own - tickets, via `approve rev-N` comments. Live prompts (C2's halts and browser - confirmations) stay Matrix-primary — polling latency makes a ticket comment a poor - interactive channel — with the outcome written back to the ticket as the record. -- **Trust:** a driving ticket is an instruction channel into an infrastructure- - changing agent → action-triggering ticket creation is operator-scoped (C2). - -### C5. Deployment & Isolation - -- Longer-lived, **stateful VM** (not disposable), dedicated to this agent (F1). -- Has **Tailscale** + scoped credentials to reach infrastructure across both sites. -- **Its MCP servers (F3) run on its own Docker host.** Its egress allowlist (F2) - therefore includes the Proxmox and UniFi endpoints at both sites, even though the - agent process never opens those connections itself. -- Updated **in place** via the container pipeline, **restart gated on an idle flag**. - ---- - -## PART D — NOTES / KNOWLEDGE (Outline) - -Shared knowledge store for both agents (sysadmin-primary). Not a message bus — see -note below. - -Requirements: -- **Read — runbooks as context.** Procedures written in Outline are fetched before - acting, constraining the agent toward *your* procedures over improvised ones. -- **Write — durable record.** Agent writes change logs / incident notes / findings - back to Outline: searchable, off-box, and an extra externalized record that - survives volume loss. -- Access via Outline **REST API**, dedicated service account, token from Bitwarden. -- **The library contract is A17**, which settles what this section left implicit: - per-agent private space plus a shared space, one writer per document, agent-owned - knowledge treated as a cache rather than authority, and the structural means of - keeping it that way. - -**Deliberately rejected:** using a shared notes store (Outline *or* an Obsidian vault) -as an **agent-to-agent message bus**. It's unstructured, race-prone (last-write-wins, -no delivery semantics), and a shared read-write store both agents touch would -undermine the dev/sysadmin isolation boundary. Agent coordination, if ever needed, -goes through the **human**, **Matrix**, or a **Vikunja ticket** handed from one agent -to another — or a real message queue if genuine multi-agent async ever arrives. - ---- - -## PART E — SUPPORTING SERVICES (installed) - -**Status: all supporting services are installed and running.** What remains for each -is per-agent *configuration* (service accounts, scoped tokens, rooms), created as -each agent is built — not standing up the service itself. - -- **Matrix homeserver** (Synapse/Conduit): ✅ installed. *Remaining per-agent:* create - the two bot identities (`@nisse-dev`, `@nisse-ops`), one room per agent + `#ops`. -- **Gitea:** ✅ installed. *Remaining per-agent:* `nisse-dev` service account + scoped - token; enable native-JSON webhooks or confirm API polling access. -- **LiteLLM proxy:** ✅ installed, and **on the path from the first LLM call** (A5) — - it is the agent VM's single LLM destination, providing per-agent virtual keys with - budget caps, central logging, vendor swap, fallback routing, and the Anthropic - passthrough. *Remaining per-agent:* mint a virtual key; configure routes for the - local inference host and each frontier vendor, with keys sourced from Bitwarden. -- **Local inference host:** 🔜 **decided in principle, hardware pending** — a - 4090-class server (Part H). Interim: a Jetson Nano running Ollama validates the - plumbing, and the `executor` role maps to a Haiku-class frontier model via LiteLLM - until the server arrives. Everything in A5, A12, and A15 assumes the eventual host - sits on the LAN behind LiteLLM. -- **Vikunja:** ✅ installed. *Remaining (sysadmin):* service account + scoped token; - webhook/poll config (Part C4). -- **Outline:** ✅ installed. *Remaining per-agent:* service account + API token - (Part D). -- **Bitwarden/Vaultwarden:** ✅ installed. *Remaining per-agent:* dedicated - minimally-scoped machine account + bootstrap token (A7). -- **LLDAP + Authelia:** ✅ installed. *Remaining per-agent:* create `svc-nisse-dev` / - `svc-nisse-ops` principals, scoped groups, and the narrow unattended-auth exception - (A8). - ---- - -## PART F — DEPLOYMENT MODEL (all agents) - -These rules apply to every agent, current and future. Parts B3 and C5 record how each -existing agent instantiates them. - -### F1. Agent hosts: every agent gets its own VM - -- **Every agent gets its own VM** (Proxmox). No agent runs as a container on a shared - host, and no two agents share a VM. -- **Why, in general:** - - *Isolation boundary between agents.* The VM is the trust boundary (Part H). A - compromised or misbehaving agent must not reach another agent's state, - credentials, or processes. - - *Per-agent credential attribution.* Everything an agent holds lives on its own - host and is issued to its own identity, so every upstream action traces back to - exactly one agent. -- **Agent-specific reasons add to this; they are not its foundation.** The Dev Agent - also needs a real Docker daemon, because it builds and runs containers (B3). An - agent that builds no code still gets a VM, for the two reasons above. - -### F2. Egress policy: per agent instance - -- **Every agent instance has its own default-deny allowlist.** Requirements differ by - agent type. No agent inherits another agent's policy. -- **The allowlist is a declared artifact,** versioned alongside the agent's - definition. Each entry is justified by the component that needs it. -- **The declaration point is fixed. The enforcement point is not.** Policy may be - enforced at the VM, at the Docker network, at the UniFi firewall, or at several of - these at once. Layering is deliberate: enforcement at the network layer survives - compromise of the agent's own host. -- **The allowlist is the union** of what the agent reaches directly and what its MCP - servers (F3) reach on its behalf. `nisse-ops` must permit Proxmox and UniFi - endpoints at both sites, even though the agent never opens those connections - itself. -- **The tailnet is `100.64.0.0/10`** (CGNAT shared address space), not RFC1918. A - rule written around RFC1918 denial does not cover it. Deny it explicitly. - -### F3. MCP servers - -**One instance per agent VM.** MCP servers run as containers on the owning agent's -Docker host. Images are shared through the OCI registry. Processes are not shared -between agents. - -- *A shared process means a shared credential.* The upstream API's own audit log - then cannot attribute a call to a specific agent. Separate instances with separate - tokens give attribution at the API layer, instead of depending on our own - transcripts. This matters most for a read-only auditing agent, such as a future - `nisse-security`. -- *An agent should not host the tooling used to inspect it.* -- *Duplication is nearly free.* These servers wrap remote HTTP APIs and hold no - state. (Stateful servers are the exception that could justify relocation. That - item is parked in Part H.) - -**One instance per (service, site, privilege level).** - -- **Site belongs in the server's identity,** using the site identifier (Part 0), not - in a `site` tool argument. A wrong-house action should require a tool that isn't - mounted, not just a wrong parameter value. -- **Read-only and elevated access are separate instances** with separate upstream - credentials, each from its own machine account (A7). The read-only instance is - mounted by default. The elevated instance is mounted only after plan approval (C2). - This puts the capability gate at the credential layer, instead of a flag inside a - third-party server. -- **Per-task tool profiles** mount only the servers that a given task's scope - requires. - -#### Rejected alternative: routed meta-tools / lazy tool registration - -Several community Proxmox and UniFi MCP servers use search-then-dispatch over a large -generated tool catalog. This is **rejected in favor of per-task tool profiles**: - -- *Two extra round-trips per call,* which is worse with a local model driving the - control loop. -- *The model can no longer discover capabilities it wasn't already looking for.* - That is backwards for exploratory diagnostic work. -- *Arguments arrive as an opaque blob,* instead of being constrained by per-tool JSON - Schema. -- *A dynamic tool list defeats prompt prefix caching,* and caching is much of what - makes a large static list affordable. - -Acceptable only when consuming a third-party server whose surface we can't trim. - -### F4. Containers, images, and restarts - -- **Containers + persistent volumes.** Stateless image; named volumes carry state - across redeploys (and allow optional persistence between deployments). -- **Image delivery:** Gitea Actions → Gitea OCI registry → Watchtower for the *pull*. -- **Gate the restart, not the pull:** an idle/"safe-to-restart" flag; Watchtower must - never restart mid-work. *Unplanned* interruptions (reboot/crash) are handled by the - durability layers (A2/A3), not by restart timing. -- **Carve-out: third-party MCP server images** (F3) are excluded from Watchtower. - They hold root-equivalent credentials to Proxmox and UniFi, so they are - digest-pinned and updated deliberately after review. -- **Survive reboots:** VM + Docker service set to start on boot; `restart: - unless-stopped`; ZFS snapshots of the state dataset (scheduled + on each approval). -- **Terraform + cloud-init:** deferred. When adopted, `bpg/proxmox` + cloud-init, - VM definitions as code, clean `destroy`/`apply`; the dev agent becomes a candidate - for immutable rebuilds. - ---- - -## PART G — BUILD ORDER - -1. **State store + durability (A2/A3)** — critical path; everything sits on it. -2. **Transport (A1, Matrix) + control loop/gating (A4)** with a **stub agent** that - just echoes plans — get the approval loop feeling right before any real LLM. -3. **LiteLLM + LLM client interface (A5).** The interface must exist at the **first - real LLM call**, not as a later cutover. Mint a virtual key with a budget cap; - verify prompt caching survives the passthrough. -4. **Canonical log schema and renderer (A10).** Before any transcript accumulates in a - provider-shaped form that would have to be migrated later. -5. **Local executor loop (A11/A12/A15)** — role resolution pinned at task start, phase - policy, failure-count escalation, reduced tool manifest, constrained decoding. -6. **Gitea layer** — polling intake, PR creation, comment/commit handling. -7. **Delegation harness + plan-gate integration (A13/A14).** Begin capturing the eval - set here (A16); the records are free once the composite events exist. -8. **Browser tool (A6)** for `nisse-dev` — test apps on the sealed network. -9. First **genuinely unattended milestone** (budget caps + ZFS rollback earn their keep - here). Crash-atomic writes and reconcile-on-boot must be in place first. -10. **Extract `nisse-core`**; build the **Sysadmin Agent** (`nisse-ops`): policy - engine (C2), SSO + scoped browser (A8/A6), secrets (A7), Vikunja control (C4), - knowledge store (A17/D) — world model first, operator procedures when the - C2 policy file they bind to exists — scheduled checks, alert intake. - - With the monorepo layout (Part H, decided), extraction is a **package split** - within one tree: move code behind a package boundary, no release machinery - required. - ---- - -## PART H — OPEN DECISIONS - -- **Inference hardware — resolved in principle, no longer blocking.** Target: a - **4090-class host (24GB VRAM)** running a **24–32B open model** at 4–5-bit - quantization (candidates: Devstral Small 24B, Qwen3-32B / Qwen3-30B-A3B, - GPT-OSS-20B). Models of this class are competent at tool calling and mechanical - edits, which puts the system in "tiering genuinely load-bearing" territory rather - than "local model as dispatcher." Consequences: - - **Context, not weights, is the VRAM pressure.** A 32B at 4-bit leaves roughly - 5GB for KV cache, and agent loops render long contexts. Model size vs. context - length vs. KV-cache quantization is an empirical trade — tune it, and the - thresholds that depend on local capability (A12 failure count, A14 consult - budget, A15 manifest size), at build-order step 5 on the real hardware. - - **Interim posture:** until the server exists, the `executor` role points at a - cheap frontier model (Haiku-class) through LiteLLM — a config entry under A11, - nothing structural, so the build order does not stall. The Jetson Nano validates - plumbing (Ollama → LiteLLM → agent) only; nothing learned from models it can run - transfers to threshold tuning. - - If the 24GB class proves insufficient in practice, additional hardware is the - fallback — the role indirection (A11) makes that swap invisible to the agents. - -The rest resolve when reached, not now. - -- **Repository layout — decided: one monorepo, three packages.** For a solo - operator, three repos tripled the admin surface (tokens, CI, webhooks, - permissions, backups) and imposed a version-pinning contract on `nisse-core` — - coordinated bumps every time the shared interface moves — while buying nothing: - the repo boundary was never a trust boundary in this design (the VM is), and - independent release cadence is irrelevant solo. A monorepo is also less friction - while `nisse-core` is being extracted from a working `nisse-dev` and its seams - are still moving. One repo can still build both agent images via path-filtered - CI. Splitting later stays easy if a real need appears; merging later would not - have been. -- **Sysadmin fallback browser access to network gear:** standing vs. per-task - hand-off. With UniFi reachable over MCP (F3), the browser is no longer the primary - path to network gear. It is a fallback for operations the UniFi API does not - expose. The open question is whether that *fallback* access is standing or handed - off per task. The lock-yourself-out risk still argues for per-task. -- **Ops gating model (C2) — decided: approved step sequences, exact matching.** A - per-tool allow/confirm taxonomy was the original plan. It would have to be - written in advance for a tool catalog that will grow large, and every mistake in - it is either friction or a hole. Approving literal step sequences makes most of - it unnecessary: every elevated write has to be an approved step anyway, and - classification is left to decide only how an approval is *presented* (protected - targets, irreversible steps). What remains open is data, best filled in with the - agent half-built in front of you: the protected-target list, each elevated - tool's target argument, and the approval expiry window. -- **Parked: bounded matching and standing approvals (C2).** Both loosen exact - matching and reuse the same matcher. Add bounded matching one tool at a time, - where exact matching causes real friction. Add standing approvals when a routine - fix has been approved by hand often enough to show its shape. -- **Approved-plan externalization — decided: Gitea-backed from the start.** Plans are - posted as issue comments on the project repo; the approval comment (author, - timestamp, revision tag) is the durable off-box record, authenticated by Gitea's - own auth. The state store keeps only a pointer ("gate 3 satisfied by comment 8841"), - so boot-time reconciliation (A3) can re-derive gate state from Gitea even after - state-volume loss — external truth wins (A2). Approval mechanics and revision - binding are specified in A4. -- **Parked: relocating stateful MCP servers off the owning agent's VM.** The trigger - is **statefulness** (a browser pool, a vector index, a warm cache), not sharing: - duplicating a stateful server duplicates its state. Relocating has a cost. A shared - server can't hold per-agent tokens, so per-agent identity has to move into the - request, through caller auth plus per-caller upstream credentials. That loses the - attribution property that makes instance-per-agent-VM (F3) clean. - Instance-per-agent-VM is the starting position. Migrate one server at a time, when - a specific server justifies it. -- **Parked: more than one active work item (0.2).** Park-and-switch is the starting - position. The pressure to relax it will come first from the Sysadmin Agent: an - alert arriving mid-diagnosis, or a duty's scheduled run coming due while a work - item is active, has to wait for a safe point. The agent's own switch (0.2) takes - the edge off: an alert waits for an item that is working, not for an operator - who is away. Allowing concurrent items would cost: - - *Serialization in the state store and control loop.* Transitions for different - items interleave, and the single orchestrator lock becomes per-item. - - *A scheduler.* Something has to decide which item's turn runs, especially with - one local inference host serving all of them. - - *Approval binding.* Two plans or halts open at once make a bare `approve` - ambiguous everywhere, so per-item rooms or strict reply threading become - mandatory rather than convenient. - - *Shared-scope conflicts.* Two active work items on one repo need separate - working trees. Two on one infrastructure scope could make changes that - interfere with each other. - - *Intent ownership.* Boot reconciliation has to sort pending intents by item, and - refusing to switch no longer keeps them single-item. -- **Open: when a work item must have an external record (0.2).** Plan-gated dev work - and ticket-driven ops work already require one. Whether every gated work item - needs one (for instance, an ops question that turns into a change to make) - is best settled once C2's step sequences are in use. -- **Open: sharing the local inference host between agents.** Each agent keeps - one active work item (0.2), but `nisse-dev` and `nisse-ops` are separate agents, - and both send their local roles through LiteLLM to the one 24GB host. llama-swap - there keeps one model resident at a time. If the two agents' local roles map to - different models, interleaved requests reload weights on every switch (10–60 s - each), and each agent waits on the other's loads. LiteLLM's per-key rate limits - don't address this: they cap one key's throughput rather than share the host, - and know nothing about which model is loaded. (Measured 2026-09-28: they only - throttled the benchmark, and the token limiter leaked until LiteLLM restarted.) - - **Preferred: one local model for every local role.** No swaps at all. It - needs one model good enough as the dev executor and for ops' Home Assistant - tool calls. The executor benchmark and the ops evaluation decide whether one - exists. More agents are planned for other jobs, which makes one shared model - more valuable still, and OpenSCAD and video work would add vision (next - item). - - **Fallback: agents tag in and out.** If no single model fits, the agents - coordinate their use of the host rather than interleave: an agent holds the - host for a stretch of work on its model, then hands it over, so a swap happens - per hand-over rather than per request. Two models co-resident (llama-swap - groups), where VRAM allows, can reduce how often that's needed. How the - hand-over works (a lease, a queue, or priority for ops alerts) is only worth - designing if the preferred option fails. -- **Parked: vision.** OpenSCAD development needs it: the language describes 3D - parts, and the only way to check a change is to render it and look. That work - may belong to `nisse-dev` or, being different enough from ordinary code, to an - agent of its own; undecided. Video-file management, planned for a future - agent, needs it too. Ordinary code work doesn't. The local candidates on - the 4090 host already accept images, and video as sampled frames, but the agent - code is text-only. Building it will need tools that return images, image content - in canonical events (A10) and both renderers, storage that keeps the durable log - (A2) small, and image tokens in the context budget. How well a model sees is - measured when that work starts, not before. - ---- - -## Repository Layout - -``` -nisse-core/ # shared library (extracted after nisse-dev works) - transport/ # matrix | telegram | slack (A1) - state/ # persistence, reconcile, migration (A2/A3) - control/ # loop, state machine, gating, ask_human (A4) - llm/ # client interface, role resolution (A5) - log/ # canonical event log + renderer (A10) - delegation/ # delegation harness, ask_expert (C1/C3) - browser/ # Playwright tool (A6) - secrets/ # Bitwarden machine-account client (A7) - sso/ # LLDAP identity + auth path (A8) - logging/ # structured operational logs (A9) - knowledge/ # Outline client, world model, procedures (A17) - -nisse-dev/ # app: sealed VM (Part B) - workflow (arch/milestone gating), Gitea PR layer, allow-all sandbox - browser → internal test network only - -nisse-ops/ # app: separate host, Tailscale (Part C) - policy engine (approved step sequences, C2), scheduled checks - Vikunja ticket intake, Outline runbooks/records - MCP → Proxmox/UniFi per site and privilege (F3); runs on its own VM - browser → admin UIs (API-first, UI fallback), confirm-first per action - -infra/ # (future) Terraform + cloud-init (Part F) - nisse-dev.tf | nisse-ops.tf | templates/ -``` - -These are three packages under one repository root (`nisse`), plus `docs/` for this -specification (Part H, decided). The module boundaries are what matter; the package -boundary is where the step-10 extraction lands. - -`log/` carries the canonical event log and its renderer (A10); `delegation/` carries -the delegation harness and `ask_expert` (A13/A14). diff --git a/docs/Architecture.md b/docs/Architecture.md index f9a6dfe..d44c49c 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,10 +1,8 @@ # Nisse — Architecture **Status: agreed (PR #83).** How and in what order it gets built is in -`docs/Development-Plan.md`. Until that plan's first milestone retires -`docs/Architecture-Build-Spec.md`, code comments still cite the old spec's section -numbers (A1–A18, B, C, F). This document uses different letters (P, L, D, O, X, Q) -so the two can't be confused meanwhile. +`docs/Development-Plan.md`. It replaced `docs/Architecture-Build-Spec.md`, which +remains in git history; its section letters (A–H) are not reused here. This document says what Nisse is, how its parts fit together, and why. It records decisions and the reasons for them. It doesn't record status or sequencing. @@ -360,6 +358,12 @@ comments) carry work and records. They are polled or received as structured JSON Comments from allowlisted operators are instructions or approvals; comments from anyone else are data (L11). +**Intake outages delay, never fail.** When Matrix, Gitea or Vikunja is +unreachable, intake backs off exponentially (capped at a few minutes), retries +indefinitely, and logs the outage. An outage delays a reaction; it never fails +work or forces a change of state. Outbound effects interrupted by an outage are +covered by intents (L8). + ### L6. Context and memory What each model sees, what persists, and how memory is written and used. @@ -980,7 +984,8 @@ it uses; nothing is shared between agents. ## Appendix — What changed from the Build Specification -For reviewing this rewrite against `docs/Architecture-Build-Spec.md`: +For readers who knew the former `docs/Architecture-Build-Spec.md` (in git +history): - **Threat model and control model (P2–P4).** Checks are built around mistakes, injection and crashes, in three layers with an action classifier, rather than a diff --git a/docs/Development-Plan.md b/docs/Development-Plan.md index ef3d97f..9010a3d 100644 --- a/docs/Development-Plan.md +++ b/docs/Development-Plan.md @@ -93,10 +93,11 @@ test Matrix room, a budget-capped LiteLLM key; configuration in `~/nisse-e2e/`. ### Phase 0 — Housekeeping -**M0.1 Retire the old spec.** *Pre-approved.* +**M0.1 Retire the old spec.** *Pre-approved.* **Done.** - Update every section reference in code, tests, `specs/` and the `CLAUDE.md` and - `README.md` files from the old spec's A–H to the new letters (table in §6). -- Delete `docs/Architecture-Build-Spec.md` (git keeps it); drop §6 from this plan. + `README.md` files from the old spec's A–H to the new letters. +- Delete `docs/Architecture-Build-Spec.md` (git keeps it), and the old-to-new + section table this plan carried for the purpose. - Acceptance: `grep` finds no reference to the old spec or its section numbers. **M0.2 A bring-up script, and an unprivileged agent user on this box.** *Pre-approved.* @@ -302,32 +303,3 @@ All 19 open issues, as of 2026-09-30: | #79 ending projects and milestones | **Superseded** | M2.5 | | #80 build artifacts in PRs | Milestone | M1.4 | | #81 no-change milestones | **Superseded** | M2.5 | - ---- - -## 6. Old section numbers → new (for M0.1, then removed) - -| Old (`Architecture-Build-Spec.md`) | New (`Architecture.md`) | -|---|---| -| 0 Naming | Naming | -| 0.1 What we're building, guiding principles | P1, P3–P8 | -| 0.2 Work model | L1 | -| A1 Transport | L5 | -| A2 State store, A3 Boot reconciliation | L8 | -| A4 Control loop and gating | L2 (loop, `stop`, `abandon`), L3 (approvals) | -| A5 LLM access | L9 | -| A6 Browser tool | D7, O4 | -| A7 Secrets, A8 SSO identity, A9 Logging | L12 | -| A10 Canonical transcript | L7 (schema), L6 (context) | -| A11 Roles, A12 Escalation, A13 Delegation, A14 `ask_expert`, A15 Local accommodations, A16 Eval capture | L9 | -| A17 Knowledge store | L10 (store), L6 (memory) | -| A18 Prompt injection | L11 | -| B1 Workflow | L2, D2 | -| B2 Requirements | D2, D4, D5 | -| B3 Deployment | D7 | -| C1–C5 Sysadmin Agent | O1–O6 (C2 → O2, C3 → O4, C4 → O5, C5 → O6) | -| D Knowledge (Outline) | L10 | -| E Supporting services | X5, and §2 here | -| F1–F4 Deployment model | X1–X4 | -| G Build order | This plan | -| H Open decisions | Q | diff --git a/docs/Tuning-Guide.md b/docs/Tuning-Guide.md index 94a6ec6..1bb0937 100644 --- a/docs/Tuning-Guide.md +++ b/docs/Tuning-Guide.md @@ -67,7 +67,7 @@ relying on them.) Two consequences: - **Context length is a memory decision, not a free setting.** The spec's - estimate (Part H) was about 5 GB left for KV cache beside a 4-bit 32B model. + estimate (the original build spec) was about 5 GB left for KV cache beside a 4-bit 32B model. At 16-bit, that's only about 20k tokens of context for Qwen3-32B. - **KV-cache quantization** stores keys and values in 8 bits (or 4). At 8 bits it halves the cache and is close to lossless. At 4 bits it is @@ -170,7 +170,7 @@ How many model calls one milestone may make before the executor stops with ### `tuning.executor.failure_limit` — default 2 How many consecutive failed `run_command` calls trigger escalation: the -sub-task is handed to a frontier model as a delegation (spec A12). A +sub-task is handed to a frontier model as a delegation (architecture L9). A successful command resets the count. Failed file reads don't count, since reading is looking around, not doing the work. @@ -192,12 +192,12 @@ reading is looking around, not doing the work. How many `ask_expert` calls one milestone may make. `ask_expert` is a single stateless question to a stronger model, with no access to the repository -(spec A14), for things like "is this API deprecated?". +(architecture L9), for things like "is this API deprecated?". - **Too low:** the executor can't get unstuck on a knowledge question it can't answer from the code. - **Too high:** a confused executor asks variations of the same question - instead of doing the work or failing. That's the circling A12 warns about. + instead of doing the work or failing. That's the circling L9 warns about. - **Deciding:** count consults per milestone, and check whether the answers changed what the executor did next. If it regularly exhausts the budget without progress, the budget is fine and the model or the plan is the @@ -206,7 +206,7 @@ stateless question to a stronger model, with no access to the repository ### `tuning.delegation` — hard caps on one delegation A delegation is a frontier model working a sub-task with full tool access -(spec A13). When a cap is hit, the delegation **fails**, and its branch and +(architecture L9). When a cap is hit, the delegation **fails**, and its branch and work are discarded, because resuming on a half-edited tree isn't safe. | Knob | Default | Caps | @@ -216,7 +216,7 @@ work are discarded, because resuming on a half-edited tree isn't safe. | `wall_clock_s` | 900 | elapsed time | - **`max_tokens` is a scope limit, not the money limit.** Spending limits live - on the LiteLLM key (spec A5). This cap stops one delegation from growing + on the LiteLLM key (architecture L9). This cap stops one delegation from growing without bound. An unbounded one could still exhaust a daily budget and take every frontier model away until it resets. - **Too low:** delegations fail just short of finishing and their work is @@ -225,7 +225,7 @@ work are discarded, because resuming on a half-edited tree isn't safe. - **Too high:** a stuck delegation burns tokens and time before failing. - **Deciding:** every delegation records the tokens it used and how long it ran (the `delegation_completed` event, captured for evaluation per spec - A16). Set each cap above what successful delegations actually use, with + L9). Set each cap above what successful delegations actually use, with room to spare. ### `tuning.tools.command_timeout_s` — default 120 diff --git a/nisse-core/README.md b/nisse-core/README.md index 184d036..ff070bb 100644 --- a/nisse-core/README.md +++ b/nisse-core/README.md @@ -1,6 +1,6 @@ # nisse-core -The shared library both Nisse agents sit on (Part A of the specification). +The shared library both Nisse agents sit on (Part L of the architecture). It does not exist as code yet: it is extracted from `nisse-dev` once that agent works, at step 10 of the build order — a package split within this repository. diff --git a/nisse-dev.example.yaml b/nisse-dev.example.yaml index c8b9c11..6f78d01 100644 --- a/nisse-dev.example.yaml +++ b/nisse-dev.example.yaml @@ -12,7 +12,7 @@ state_db: ~/nisse-demo-workspace/state.db workspace: ~/nisse-demo-workspace/project # the clone project: demo -llm: # A5: LiteLLM is the only LLM destination +llm: # L9: LiteLLM is the only LLM destination base_url: http://litellm.example:4000 token: ${NISSE_LLM_TOKEN} # Ceiling on each response. The model's own max_output_tokens in LiteLLM @@ -22,20 +22,20 @@ llm: # A5: LiteLLM is the only LLM destination # hung connection; slow local models can take minutes over a long answer. request_timeout_s: 600 -roles: # A11: role -> LiteLLM model alias +roles: # L9: role -> LiteLLM model alias executor: qwen3.8-27b # the local tier: best executor in the benchmark (PR #55) planner: claude-opus-5-5 # high-end judgement: plans a weaker executor follows delegate: claude-opus-5-5 # takes over what the executor got stuck on consultant: claude-haiku-4-5 # one question at a time, no tools: cheap is enough -matrix: # A1 +matrix: # L5 homeserver: https://matrix.example user_id: "@nisse-dev:example" token: ${NISSE_MATRIX_TOKEN} room_id: "!roomid:example" operators: ["@you:example"] -gitea: # B2 +gitea: # D4, D5 url: https://gitea.example token: ${NISSE_GITEA_TOKEN} repo: owner/demo-repo @@ -48,10 +48,10 @@ gitea: # B2 tuning: executor: max_turns: 40 # model calls per milestone before it stops as capped - failure_limit: 2 # consecutive failed commands before escalating (A12) + failure_limit: 2 # consecutive failed commands before escalating (L9) expert: - consult_budget: 3 # ask_expert calls per milestone (A14) - delegation: # hard caps on one delegation (A13) + consult_budget: 3 # ask_expert calls per milestone (L9) + delegation: # hard caps on one delegation (L9) max_turns: 30 max_tokens: 200000 wall_clock_s: 900 diff --git a/nisse-dev/CLAUDE.md b/nisse-dev/CLAUDE.md index 2feb0d4..3cfe165 100644 --- a/nisse-dev/CLAUDE.md +++ b/nisse-dev/CLAUDE.md @@ -1,10 +1,10 @@ # nisse-dev -The Dev Agent (Part B of the Nisse system). A thin application over the shared +The Dev Agent (Part D of the Nisse architecture). A thin application over the shared `nisse-core` package. **Read the architecture before making design decisions here:** -`docs/Architecture-Build-Spec.md` at the repo root. Part B specifies this agent; -Part A specifies the library contracts it depends on. Requirements that look like +`docs/Architecture.md` at the repo root. Part D specifies this agent; +Part L specifies the library contracts it depends on. Requirements that look like free choices in this package — gating structure, state durability, LLM access paths — are usually settled there. diff --git a/nisse-dev/README.md b/nisse-dev/README.md index 6dfef42..5ca1c8a 100644 --- a/nisse-dev/README.md +++ b/nisse-dev/README.md @@ -4,5 +4,5 @@ The Dev Agent: plans and implements software projects, produces Gitea PRs for hu review, and reacts to review comments and commits. A thin application over the shared `nisse-core` library. -**Part B** of the specification at [`../docs/Architecture-Build-Spec.md`](../docs/Architecture-Build-Spec.md) -covers this agent; **Part A** covers the library it sits on. +**Part D** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md) +covers this agent; **Part L** covers the library it sits on. diff --git a/nisse-dev/src/nisse_dev/agent.py b/nisse-dev/src/nisse_dev/agent.py index 2f5355f..cb9693d 100644 --- a/nisse-dev/src/nisse_dev/agent.py +++ b/nisse-dev/src/nisse_dev/agent.py @@ -33,7 +33,7 @@ from nisse_dev.tools import Workspace from nisse_dev.worker import SerialWorker # How long shutdown waits for running work before leaving the store to the -# durability layer (A2/A3). A milestone can run far longer than a service +# durability layer (L8). A milestone can run far longer than a service # manager allows for a stop, and a hard exit mid-work is already safe. SHUTDOWN_WAIT_S = 10.0 @@ -52,14 +52,14 @@ def discover_models(config: AgentConfig, oplog: OpLog) -> ModelCatalog: catalog = ModelCatalog.discover(config.llm_base_url, config.llm_token) catalog.validate(config.roles.pinned()) # Every role's context window must be known before a request is built - # against it (A10): a guess that is too large reaches a server that may + # against it (L6): a guess that is too large reaches a server that may # silently cut the prompt. catalog.require_limits(config.roles.pinned()) for role in TOOL_DRIVING_ROLES: facts = catalog.facts(config.roles.resolve(role)) if facts.supports_tools is False: # A warning, not a refusal: models LiteLLM declares tool-less are - # observed driving tool loops anyway, and A15 expects weak local + # observed driving tool loops anyway, and L9 expects weak local # models to be accommodated rather than excluded. oplog.emit( "model_tools_unsupported", @@ -83,7 +83,7 @@ class Agent: _stopped: threading.Event def start(self) -> None: - """Boot in the order A3 requires: reconcile against external truth + """Boot in the order L8 requires: reconcile against external truth before accepting a single operator command.""" self.worker.call(self.orchestrator.start) self.transport.start() @@ -108,7 +108,7 @@ class Agent: try: self.worker.call(self.store.close, timeout=SHUTDOWN_WAIT_S) except TimeoutError: - pass # still working; exiting mid-work is what A2/A3 are for + pass # still working; exiting mid-work is what L8 are for self.worker.stop(timeout=SHUTDOWN_WAIT_S) self._stopped.set() @@ -151,7 +151,7 @@ def make_llm_clients( ) ) # Delegations prefer the vendor-native route; fidelity is cheap - # elsewhere (A5). Which route a given model gets is decided by + # elsewhere (L9). Which route a given model gets is decided by # select_client, not by where the caller happens to sit. passthrough = budgeted( AnthropicPassthroughClient( diff --git a/nisse-dev/src/nisse_dev/approvals.py b/nisse-dev/src/nisse_dev/approvals.py index c6d4c0e..3952b73 100644 --- a/nisse-dev/src/nisse_dev/approvals.py +++ b/nisse-dev/src/nisse_dev/approvals.py @@ -1,4 +1,4 @@ -"""Plan externalisation and Gitea-backed approvals (spec A4, Part H). +"""Plan externalisation and Gitea-backed approvals (architecture L3, D2). Plans are posted as comments on a per-project Gitea issue. The approval comment — its author, timestamp, and revision tag — is the durable off-box @@ -7,7 +7,7 @@ pointer to it, so boot reconciliation can re-derive gate state from Gitea even after the state volume is lost: external truth wins. Approvals arriving here bind to a revision exactly as they do over transport -(A4). The first valid approval on any channel wins; the orchestrator echoes +(L3). The first valid approval on any channel wins; the orchestrator echoes the outcome to the other channel so the records converge. """ diff --git a/nisse-dev/src/nisse_dev/cancel.py b/nisse-dev/src/nisse_dev/cancel.py index 4531378..9e459a3 100644 --- a/nisse-dev/src/nisse_dev/cancel.py +++ b/nisse-dev/src/nisse_dev/cancel.py @@ -1,4 +1,4 @@ -"""The operator's kill switch, as the working loops see it (spec A4). +"""The operator's kill switch, as the working loops see it (architecture L2). `stop` has to reach work that is already running, from a thread that is not running it. So it is a flag, set by whoever hears the command and checked by diff --git a/nisse-dev/src/nisse_dev/canonical.py b/nisse-dev/src/nisse_dev/canonical.py index 9989493..d3cfe29 100644 --- a/nisse-dev/src/nisse_dev/canonical.py +++ b/nisse-dev/src/nisse_dev/canonical.py @@ -1,4 +1,4 @@ -"""Canonical transcript schema (spec A10). +"""Canonical transcript schema (architecture L7). The durable record is this event log, never a provider-shaped ``messages`` array; provider request bodies are rendered from it at call time. Canonical @@ -6,7 +6,7 @@ tool-call IDs are minted locally — provider IDs are mapped at the boundary and never used as a join key. Transcript appends are the conversation record itself, not state-machine -mutations, so they are deliberately outside the A9 state-change taxonomy. +mutations, so they are deliberately outside the L12 state-change taxonomy. """ import uuid @@ -59,7 +59,7 @@ class DelegationStarted: model: str # the alias asked for, e.g. "remote-architecture" caps: dict[str, Any] # turns / tokens / wall-clock limits # What the alias resolved to at task start. An alias is free to change - # models between tasks, so eval history (A16) keyed on the alias alone + # models between tasks, so eval history (L9) keyed on the alias alone # would average results across whatever it happened to point at. backing_model: str = "" diff --git a/nisse-dev/src/nisse_dev/catalog.py b/nisse-dev/src/nisse_dev/catalog.py index 8bb97d1..99b1912 100644 --- a/nisse-dev/src/nisse_dev/catalog.py +++ b/nisse-dev/src/nisse_dev/catalog.py @@ -1,4 +1,4 @@ -"""Runtime model discovery (spec A5, A11, A13). +"""Runtime model discovery (architecture L9). LiteLLM is the VM's single LLM destination, which makes it the authority on what any given model *is*. Asking it at startup keeps vendor knowledge in one @@ -186,7 +186,7 @@ class ModelCatalog: ) def pinned(self, roles: Mapping[str, str]) -> dict[str, dict[str, Any]]: - """The resolved mapping to record at task start (A11, A16). + """The resolved mapping to record at task start (L9). Recording only the alias would let an alias that silently changes models muddle the eval history behind it: "remote-architecture: 80% @@ -217,9 +217,9 @@ def select_client( ) -> LLMClient: """Choose the route from what the model *is*, not from what it is called. - A13 spells out the rule: use the vendor-native adapter where one exists, + L9 spells out the rule: use the vendor-native adapter where one exists, and otherwise provisionally accept LiteLLM's normalization for that - vendor -- validating it against the A16 eval set before trusting it with + vendor -- validating it against the L9 eval set before trusting it with delegations. There is deliberately no name-based fallback here: a second heuristic could disagree with the proxy, and an unknown model raises rather than quietly taking the normalized route. diff --git a/nisse-dev/src/nisse_dev/config.py b/nisse-dev/src/nisse_dev/config.py index 0ae0b42..bb733d2 100644 --- a/nisse-dev/src/nisse_dev/config.py +++ b/nisse-dev/src/nisse_dev/config.py @@ -1,4 +1,4 @@ -"""Agent configuration (spec A5: nothing hardcoded). +"""Agent configuration (architecture L9: nothing hardcoded). Configuration is a YAML file: roles, connections, and the tuning knobs (see docs/Tuning-Guide.md). It holds no secrets. A credential is written as a diff --git a/nisse-dev/src/nisse_dev/context.py b/nisse-dev/src/nisse_dev/context.py index c0f4a07..0724b9a 100644 --- a/nisse-dev/src/nisse_dev/context.py +++ b/nisse-dev/src/nisse_dev/context.py @@ -1,4 +1,4 @@ -"""The context budget (spec A10: token budgeting is a render-time concern). +"""The context budget (architecture L6: token budgeting is a render-time concern). Every model call re-sends a transcript that only grows, and a model server does not always refuse one that no longer fits: Ollama silently drops the diff --git a/nisse-dev/src/nisse_dev/control.py b/nisse-dev/src/nisse_dev/control.py index a0dfdef..7b8e322 100644 --- a/nisse-dev/src/nisse_dev/control.py +++ b/nisse-dev/src/nisse_dev/control.py @@ -1,4 +1,4 @@ -"""Control loop and state machine (spec A4, B1 subset). +"""Control loop and state machine (architecture L2, L3; retired by M2.6). Gating is structural, not advisory: the agent's only actions are ``SubmitPlan`` (posts the plan for approval and ends the agent's turn) and ``AskHuman`` @@ -8,7 +8,7 @@ arrives over transport. Every position change is checkpointed through the state store before the loop continues, so a restart lands mid-gate, not mid-air. All of it runs on one worker (nisse_dev.worker), never on the thread that -delivered a message. The one exception is `stop` (A4), which has to reach +delivered a message. The one exception is `stop` (L2), which has to reach work that is already running: the transport's thread sets the cancel flag directly, and the worker records the stop once the work has returned. """ @@ -57,12 +57,12 @@ TRANSITIONS = { (AWAIT_MILESTONE_SIGNOFF, MILESTONE_PLANNING), (AWAIT_MILESTONE_SIGNOFF, EXECUTING), (EXECUTING, PR_OPEN), - # PR acceptance is the milestone's final gate (B1): a merged PR is the + # PR acceptance is the milestone's final gate (D5): a merged PR is the # durable record, so completion returns to planning or to idle rather # than passing through a separate FINISHED state. (PR_OPEN, MILESTONE_PLANNING), (PR_OPEN, IDLE), - # abandon (A4, B2): the operator may end the task from any waiting state. + # abandon (L2): the operator may end the task from any waiting state. # (AWAIT_INPUT may go anywhere already.) (AWAIT_ARCH_SIGNOFF, IDLE), (AWAIT_MILESTONE_SIGNOFF, IDLE), @@ -91,7 +91,7 @@ class PlanningError(RuntimeError): class PlanningStopped(RuntimeError): - """The operator said `stop` (A4) before a planning turn could run.""" + """The operator said `stop` (L2) before a planning turn could run.""" @dataclass(frozen=True) @@ -193,7 +193,7 @@ def _project_name(text: str) -> str: def _fresh_data() -> dict[str, Any]: return { # Latest revision per gate; revision numbers themselves run across - # the whole work item (last_rev), so rev-N names one plan (A4). + # the whole work item (last_rev), so rev-N names one plan (L3). "revs": {}, "last_rev": 0, # The revision last put up for sign-off: a reply that names none @@ -205,13 +205,13 @@ def _fresh_data() -> dict[str, Any]: "pending": None, "milestone": 1, "task_id": None, - "roles": None, # A11 mapping, pinned at task start + "roles": None, # L9 mapping, pinned at task start # What each pinned alias resolved to, discovered from LiteLLM. An # alias is free to point somewhere else tomorrow, so the transcript - # has to say what actually answered (A11, A16). + # has to say what actually answered (L9). "models": None, "pr": None, # the open PR for the current milestone, once published - # Pointer to the Gitea plan record (Part H): the state store holds + # Pointer to the Gitea plan record (D2): the state store holds # only this, so gate state can be re-derived from Gitea after a # state-volume loss. "board": None, @@ -244,7 +244,7 @@ class Orchestrator: self._executor = executor self._publisher = publisher self._operators = set(operators) - # Per-channel allowlists (A4): a Matrix MXID and a Gitea login + # Per-channel allowlists (L3): a Matrix MXID and a Gitea login # are different names for the same person, and neither implies # the other. self._gitea_operators = set(gitea_operators) @@ -276,14 +276,14 @@ class Orchestrator: transport.on_message(self._handle) def start(self, reconcilers: Mapping[str, Reconciler] | None = None) -> None: - # A3 step 5 runs before the generic sweep: an interrupted delegation is + # L8 boot step 5 runs before the generic sweep: an interrupted delegation is # resumed or discarded by inspecting the branch and the log, and only a # genuinely unclean cut is left for the sweep below to gate on. if self._delegate is not None: with self._long_work(): self._delegate.recover(self._transport.send_message) # The publisher owns the external-truth query for its own intents, so - # a configured publisher always contributes its reconciler (A3). + # a configured publisher always contributes its reconciler (L8). registered: dict[str, Reconciler] = dict(reconcilers or {}) if self._publisher is not None: registered = {**self._publisher.reconcilers(), **registered} @@ -317,7 +317,7 @@ class Orchestrator: pending={"kind": "boot", "interrupted": state.state}, ) elif state.state == AWAIT_INPUT and self._data.get("pending"): - # Waiting on the operator is a committed state (A3 step 8): + # Waiting on the operator is a committed state (L8 boot step 4): # nothing was in flight, and the question and what answering it # resumes are durable. Ask it again rather than replacing it. pending = self._data["pending"] @@ -326,7 +326,7 @@ class Orchestrator: + pending.get("prompt", "my last question still stands") ) elif state.state == EXECUTING and (published := self._published_pr()): - # A3 step 4: Gitea shows this milestone's PR open, so it was + # L8 boot step 3: Gitea shows this milestone's PR open, so it was # published and only PR_OPEN went unrecorded. Adopt the PR rather # than ask, and never publish it again. Nothing watched it yet, # so every comment on it is new. @@ -347,7 +347,7 @@ class Orchestrator: "review comments" ) elif state.state == PR_OPEN: - # Waiting on review is a committed state like a gate (A3 step + # Waiting on review is a committed state like a gate (L8 boot step # 8): resume, and watch the PR again from its cursor. pr = self._data.get("pr") or {} self._watch_pr(pr) @@ -385,7 +385,7 @@ class Orchestrator: ) else: # Interrupted mid-turn (planning, executing, unanswered question): - # never blind-resume (A3.6). + # never blind-resume (L8 boot step 5). self._await_input( cause=f"boot: restarted in {state.state}", prompt=f"restarted mid-{state.state}; {_BOOT_CHOICES}", @@ -400,7 +400,7 @@ class Orchestrator: text = msg.text.strip() if command_form(text) == "stop": # Straight to the flag, not behind the work in the queue: the - # work is what it has to reach (A4). + # work is what it has to reach (L2). if self._worker.busy: self._cancel.set() self._worker.submit(lambda: self._finish_stop(msg)) @@ -478,7 +478,7 @@ class Orchestrator: def _halt_for_stop( self, *, what: str, note: str, next_step: str, pending: dict[str, Any] ) -> None: - """A4: stopped work moves to AWAIT_INPUT, and what happens next is the + """L2: stopped work moves to AWAIT_INPUT, and what happens next is the operator's call. The prompt asks why, because the model should hear the reason before it tries again.""" self._halted_by_stop = True @@ -494,7 +494,7 @@ class Orchestrator: def _handle_now(self, msg: InboundMessage, about: int | None = None) -> None: # Every operator message enters the canonical transcript, so planning # turns see feedback and answers as conversation rather than as - # out-of-band parameters (A10). + # out-of-band parameters (L7). self._store.append_event( UserMessage( text=(msg.original or msg.text).strip(), @@ -555,7 +555,7 @@ class Orchestrator: def _begin_project(self, project: str) -> None: task_id = f"{project}-{uuid.uuid4().hex[:8]}" - # Pin the role mapping now (A11): a mid-task config change must not + # Pin the role mapping now (L9): a mid-task config change must not # split one task across two planners. pinned = self._roles.pinned() if self._roles is not None else None resolved = ( @@ -666,7 +666,7 @@ class Orchestrator: def _apply_gate_reply( self, *, sender: str, text: str, channel: str, about: int | None = None ) -> None: - """Shared by every approval source (A4). The allowlist is per channel: + """Shared by every approval source (L3). The allowlist is per channel: a Matrix MXID and a Gitea login are different names, and holding one says nothing about holding the other.""" gate_data = self._data["gate"] @@ -771,7 +771,7 @@ class Orchestrator: def _echo_outcome(self, deciding_channel: str, text: str) -> None: """A gate satisfied on one channel is echoed to the other, so neither - record silently disagrees about what was approved (A4).""" + record silently disagrees about what was approved (L3).""" board_state = self._data.get("board") if deciding_channel != "gitea" and self._board is not None and board_state: self._board.echo(board_state["issue"], text) @@ -792,7 +792,7 @@ class Orchestrator: # Which comment put which revision up: a Gitea reply binds to the # latest plan posted before it, since comment IDs only grow. board_state.setdefault("posts", []).append([post.comment_id, rev]) - # Approvals can arrive on the issue as well as over transport (A4), + # Approvals can arrive on the issue as well as over transport (L3), # so watch it for as long as the gate stands. A new revision skips # what came before it: an early "approve" must not approve it. board_state["last_comment"] = self._watch( @@ -801,7 +801,7 @@ class Orchestrator: self._save(f"{gate_name} rev-{rev} posted to Gitea") def handle_plan_comment(self, comment: Any) -> None: - """A comment on the project issue may satisfy an open plan gate (A4). + """A comment on the project issue may satisfy an open plan gate (L3). Approvals bind to a revision here exactly as over transport, and the first valid approval on either channel wins. Called from the poller @@ -837,12 +837,12 @@ class Orchestrator: ) def handle_review_comment(self, comment: Any) -> None: - """React to a PR review comment (B2). Called from the poller thread. + """React to a PR review comment (D5). Called from the poller thread. Comment authors are checked against the Gitea operator allowlist: a PR comment is an instruction channel into an agent that writes - code, so it is operator-scoped like any other (A4, and the same - reasoning C4 applies to tickets). + code, so it is operator-scoped like any other (L11, and the same + reasoning O5 applies to tickets). """ self._worker.call( lambda: self._settled( @@ -991,7 +991,7 @@ class Orchestrator: ) def _abandon(self, sender: str) -> None: - """End the task on the operator's word (A4: continue, re-plan, or + """End the task on the operator's word (L2: continue, re-plan, or abandon), closing its Gitea records. Only reached from a waiting state: while work runs the reply is "busy; say 'stop'" instead. @@ -1196,7 +1196,7 @@ class Orchestrator: ) def _resume_after_restart(self, pending: dict[str, Any], text: str) -> None: - """Answer to the question a restart asks (A3 step 7). Never a blind + """Answer to the question a restart asks (L8 boot step 5). Never a blind resume: 'continue' re-runs the interrupted planning turn, or returns an interrupted milestone to its approved plan to be started again.""" choice = command_form(text) diff --git a/nisse-dev/src/nisse_dev/delegation.py b/nisse-dev/src/nisse_dev/delegation.py index 9cb2560..5535e7d 100644 --- a/nisse-dev/src/nisse_dev/delegation.py +++ b/nisse-dev/src/nisse_dev/delegation.py @@ -1,4 +1,4 @@ -"""Delegation harness (spec A13). +"""Delegation harness (architecture L9). Escalation is delegation, not consultation: a frontier model receives a scoped sub-task, works autonomously with full tool access, and hands back. The local @@ -17,7 +17,7 @@ resuming on top of a half-edited tree. **Checkpointing as it happens.** Every tool call, result, and assistant turn is written to the canonical log when it occurs, never batched at handback. -That is what makes a delegation resumable after a crash (A3). +That is what makes a delegation resumable after a crash (L8). Scope is bounded by construction: a delegation is handed a sub-task the approved plan already contains. It is smart, not authorised to widen the work. @@ -144,7 +144,7 @@ class DelegationResult: The work on the branch may be perfectly good, so it is preserved and the intent left pending: the next boot treats it exactly like a crash - mid-delegation (A3) and resumes if the cut was clean. Discarding here + mid-delegation (L8) and resumes if the cut was clean. Discarding here would throw away work because the budget ran out. """ return self.status == "interrupted" @@ -152,7 +152,7 @@ class DelegationResult: @property def usable(self) -> bool: """Only a successful handback may be built upon. A cap breach, a - failure or an operator's stop leaves a half-edited tree, which A13 + failure or an operator's stop leaves a half-edited tree, which L9 says must gate rather than be resumed.""" return self.status == "handback" and self.handback is not None @@ -190,7 +190,7 @@ class Delegation: started_at = self._now() base_branch = self._git.current_branch() # Recorded before the branch exists, so a crash anywhere after this - # leaves a durable statement of what was in flight (A2/A3). + # leaves a durable statement of what was in flight (L8). intent_id = self._store.begin_intent( DELEGATION_INTENT, { @@ -234,12 +234,12 @@ class Delegation: ) if result.interrupted: # Leave the branch and the intent alone: this delegation is not - # finished, it was cut off, and A3's resume rule is exactly the + # finished, it was cut off, and L8's resume rule is exactly the # machinery for picking it back up. return result if not result.usable: # Abort discards the branch: the caller must never resume on top - # of a partially-edited tree (A13). + # of a partially-edited tree (L9). self._abandon(branch, base_branch) # Settled either way: an aborted delegation is a *finished* one, and # leaving the intent pending would make the next boot think work was @@ -248,12 +248,12 @@ class Delegation: return result def resume(self, intent: Intent) -> DelegationResult: - """Continue a delegation the process died in the middle of (A3 step 5). + """Continue a delegation the process died in the middle of (L8 boot step 5). The transcript is already in the canonical log, so the loop re-renders it and carries on. No new branch and no second ``delegation_started``: this is the same delegation, which is why its vendor-opaque reasoning - may still be replayed (A10 defines a session as the delegation, not the + may still be replayed (L7 defines a session as the delegation, not the process lifetime). """ payload = intent.payload @@ -284,7 +284,7 @@ class Delegation: return result def recover(self, notify: Callable[[str], None]) -> DelegationResult | None: - """Sweep pending delegation intents at boot (A3 step 5). + """Sweep pending delegation intents at boot (L8 boot step 5). Settles what is already finished, discards what never started, resumes a clean cut — announcing it with a *notice*, not a question, because a @@ -363,7 +363,7 @@ class Delegation: tokens=spent, ) # Metered from what the provider reported, not estimated. The cap - # is a scope limit (A13), distinct from the money cap A5 keeps on + # is a scope limit (L9), distinct from the money cap L9 keeps on # the LiteLLM key — but an unbounded delegation can still exhaust # a daily budget and take external models away until it resets. usage = getattr(self._client, "last_usage", {}) or {} @@ -380,7 +380,7 @@ class Delegation: calls: list[ToolCall] = [] for event in produced: - # Checkpoint as it arrives, never batched at handback (A13): + # Checkpoint as it arrives, never batched at handback (L9): # this is what makes the delegation resumable after a crash. self._store.append_event(event) if isinstance(event, ToolCall): @@ -475,7 +475,7 @@ class Delegation: # Commit each turn's work onto the delegation branch, so that # discarding the branch really does discard the work, and so a - # crash leaves the branch at a known commit (A3). + # crash leaves the branch at a known commit (L8). self._commit_turn(turn) return DelegationResult( @@ -504,7 +504,7 @@ class Delegation: def _parse_handback(arguments: dict[str, Any]) -> Handback | None: - """Validated locally rather than trusted: A14's rule for prompt-level + """Validated locally rather than trusted: L9's rule for prompt-level structured output applies to the handback too.""" required = ("changed", "now_true", "next") if any(not str(arguments.get(key, "")).strip() for key in required): @@ -521,10 +521,10 @@ def _parse_handback(arguments: dict[str, Any]) -> Handback | None: def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler: - """A3 step 5: decide whether an interrupted delegation may be resumed. + """L8 boot step 5: decide whether an interrupted delegation may be resumed. The judgement is an observable check, never model self-assessment — the - same reasoning as A12. Returns: + same reasoning as L9. Returns: - ``True`` the delegation is settled or cleanly resumable - ``False`` it never really started, so there is nothing to resume @@ -534,7 +534,7 @@ def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler: existing and carrying the delegation's commits: per-turn shas are not recorded in the log today, so a branch rewritten out from under us would not be detected. Stated rather than glossed, because the check is weaker - than A3's wording implies. + than L8's wording implies. """ def check(intent: Intent) -> bool | None: @@ -549,7 +549,7 @@ def delegation_reconciler(store: StateStore, git_repo: GitRepo) -> Reconciler: def delegation_verdict( store: StateStore, git_repo: GitRepo, intent: Intent ) -> Literal["resume", "settled", "absent", "gate"]: - """The A3 step 5 decision, separated from the Reconciler contract because + """The L8 boot step 5 decision, separated from the Reconciler contract because boot must *act* on it — resuming is not the same as judging settled.""" branch = intent.payload.get("branch") if not branch: @@ -564,7 +564,7 @@ def delegation_verdict( last = events[-1].event if isinstance(last, ToolCall): # A call with no recorded result: its effect is unknown and cannot be - # verified by inspection, so gate (A3). + # verified by inspection, so gate (L8). return "gate" if not isinstance(last, AssistantText | ToolResult): return "gate" diff --git a/nisse-dev/src/nisse_dev/envfile.py b/nisse-dev/src/nisse_dev/envfile.py index 3361cd2..81dc603 100644 --- a/nisse-dev/src/nisse_dev/envfile.py +++ b/nisse-dev/src/nisse_dev/envfile.py @@ -1,4 +1,4 @@ -"""Minimal .env loader (spec A5: config from env/.env, nothing hardcoded). +"""Minimal .env loader (architecture L12: secrets from env/.env, nothing hardcoded). Deliberately tiny: KEY=VALUE lines, comments and blanks ignored, existing environment variables never overridden (real env wins over the file). diff --git a/nisse-dev/src/nisse_dev/evals.py b/nisse-dev/src/nisse_dev/evals.py index 75ae58b..56e6a8e 100644 --- a/nisse-dev/src/nisse_dev/evals.py +++ b/nisse-dev/src/nisse_dev/evals.py @@ -1,4 +1,4 @@ -"""Delegation and consult evaluation capture (spec A16). +"""Delegation and consult evaluation capture (architecture L9). Each ``delegation_started`` / ``delegation_completed`` pair is already a clean input-output record; this pairs them up and attaches a verdict, so "best model @@ -40,7 +40,7 @@ def collect(store: StateStore, after: int = 0) -> list[EvalRecord]: """Pair each start with the completion that follows it. A start with no completion is skipped rather than reported half-formed: - it means the process died mid-delegation, which A3 handles, and an + it means the process died mid-delegation, which L8 handles, and an unfinished record would pollute the eval set. """ verdicts = store.outcomes() diff --git a/nisse-dev/src/nisse_dev/executor.py b/nisse-dev/src/nisse_dev/executor.py index 1eeb75e..3c6d8c7 100644 --- a/nisse-dev/src/nisse_dev/executor.py +++ b/nisse-dev/src/nisse_dev/executor.py @@ -1,10 +1,10 @@ -"""Local executor loop (spec A12 escalation triggers, A15 accommodations). +"""Local executor loop (architecture L9 escalation triggers, L9 accommodations). Runs the approved plan with the executor role — the cheap local model doing mechanical work. Every tool call and result is checkpointed to the canonical -log as it happens (A13), so a crash mid-milestone leaves a resumable record. +log as it happens (L9), so a crash mid-milestone leaves a resumable record. -Escalation is decided from outside the model (A12): consecutive command +Escalation is decided from outside the model (L9): consecutive command failures and hard caps on turns, never the model's own claim that it is stuck or finished-enough. Until delegation exists (step 7), an escalation gates to the human with the failures attached rather than engaging a frontier model. @@ -115,7 +115,7 @@ class LLMExecutor: def _escalate( self, milestone: int, failures: int, last_failure: str ) -> ExecutorOutcome | None: - """Hand the sub-task to a stronger model, per A12. + """Hand the sub-task to a stronger model, per L9. Returns None when the delegation handed back usable work and the local loop should carry on; an ExecutorOutcome when the caller must stop. @@ -124,7 +124,7 @@ class LLMExecutor: if self._delegate is None: return ExecutorOutcome("escalate", note) - # A12 says the failures go in the brief: the delegate must not have to + # L9 says the failures go in the brief: the delegate must not have to # rediscover what the local model already learned the hard way. brief = ( f"Milestone {milestone} is stuck. The local executor tried and its " @@ -140,14 +140,14 @@ class LLMExecutor: return None if result.status == "stopped": return ExecutorOutcome("stopped", f"during a delegation: {result.note}") - # A13: a cap breach or failure leaves a half-edited tree, so the local + # L9: a cap breach or failure leaves a half-edited tree, so the local # loop must not resume on top of it. return ExecutorOutcome( "escalate", f"{note}; delegation {result.status}: {result.note}" ) def _tools(self) -> list[dict[str, Any]]: - # A12: ask_expert exists in addition to the observable escalation + # L9: ask_expert exists in addition to the observable escalation # triggers, never as a replacement for them. if self._expert is None: return [*EXECUTOR_TOOLS, FINISH_TOOL] @@ -203,7 +203,7 @@ class LLMExecutor: prose_nudged = False for turn in range(self._max_turns): - # A4: `stop` takes effect at the next tool boundary. The start of + # L2: `stop` takes effect at the next tool boundary. The start of # a turn is one, and so is the gap between two tool calls below. if self._cancel.requested: return ExecutorOutcome("stopped", f"before turn {turn + 1}") @@ -267,7 +267,7 @@ class LLMExecutor: elif call.tool not in offered: # A model inventing tools is not doing the work, and with # no server enforcement nothing else would stop it, so it - # counts toward escalation like a failed command (A12). + # counts toward escalation like a failed command (L9). outcome = ToolOutcome( f"no such tool: {call.tool}; the tools are " + ", ".join(sorted(offered)), @@ -294,7 +294,7 @@ class LLMExecutor: 0 if outcome.success else consecutive_failures + 1 ) if consecutive_failures >= self._failure_limit: - # A12: this is the trigger delegation exists for. + # L9: this is the trigger delegation exists for. # Escalating to the human is the fallback when there # is no delegate, not the intended path. escalation = self._escalate( diff --git a/nisse-dev/src/nisse_dev/expert.py b/nisse-dev/src/nisse_dev/expert.py index 181a9e1..68d6039 100644 --- a/nisse-dev/src/nisse_dev/expert.py +++ b/nisse-dev/src/nisse_dev/expert.py @@ -1,23 +1,23 @@ -"""`ask_expert` (spec A14). +"""`ask_expert` (architecture L9). The one case delegation does not cover: a question needing no repo access — "which of these two approaches is better", "is this API deprecated". A single stateless call, a text answer, no session state, no diff application. -Two constraints from A14 shape the implementation: +Two constraints from L9 shape the implementation: **Structured output is contracted at the prompt level, not the API level.** Vendors differ on JSON mode and schema enforcement, so the answer is parsed and validated against a local schema, with one retry. This runs over LiteLLM's -*normalized* route, which A13 reserves for calls where fidelity is cheap. +*normalized* route, which L9 reserves for calls where fidelity is cheap. **A per-task budget**, so a confused executor cannot spend twenty consults circling. The count comes from the canonical log rather than a counter in memory: it survives a restart, and it is observable from outside the model, -which is the same reasoning A12 applies to escalation triggers. +which is the same reasoning L9 applies to escalation triggers. Consults are recorded as ``delegation_started`` / ``delegation_completed`` with -role ``consultant``. That is deliberate — A16 wants a "consult/delegation eval +role ``consultant``. That is deliberate — L9 wants a "consult/delegation eval set", and reusing the composite events gets consult evaluation for free rather than inventing a parallel record. """ @@ -150,7 +150,7 @@ class ExpertDesk: last = "no reply" for attempt in (1, 2): try: - # Stateless by design (A14): the question travels as a + # Stateless by design (L9): the question travels as a # synthesised message that is never appended to the log, so a # consult neither depends on nor leaks repo context. It cannot # ride in the system prompt alone — the Messages API requires diff --git a/nisse-dev/src/nisse_dev/gates.py b/nisse-dev/src/nisse_dev/gates.py index c24f93c..7b2fbf2 100644 --- a/nisse-dev/src/nisse_dev/gates.py +++ b/nisse-dev/src/nisse_dev/gates.py @@ -1,4 +1,4 @@ -"""Approval gating primitives (spec A4). +"""Approval gating primitives (architecture L3). Approvals bind to a plan revision, never to "the current gate": every revision carries a monotonic ``rev-N`` tag, an approval naming a stale revision is diff --git a/nisse-dev/src/nisse_dev/git.py b/nisse-dev/src/nisse_dev/git.py index 7db2ef9..55e3baf 100644 --- a/nisse-dev/src/nisse_dev/git.py +++ b/nisse-dev/src/nisse_dev/git.py @@ -1,4 +1,4 @@ -"""Git operations for the agent's workspace (spec B2 outbound). +"""Git operations for the agent's workspace (architecture D4). Push authentication uses the Gitea service-account token embedded in the remote URL. The token is never written to disk in the repo config and is @@ -110,7 +110,7 @@ class GitRepo: def discard_branch(self, branch: str, *, back_to: str) -> None: """Throw away a branch and everything on it. - Used when a delegation aborts: A13 gives a delegation the same + Used when a delegation aborts: L9 gives a delegation the same atomicity that reconciliation gives an interrupted transition, and half-finished work must not survive to be built upon. """ diff --git a/nisse-dev/src/nisse_dev/gitea.py b/nisse-dev/src/nisse_dev/gitea.py index 0bdd3c6..386123e 100644 --- a/nisse-dev/src/nisse_dev/gitea.py +++ b/nisse-dev/src/nisse_dev/gitea.py @@ -1,12 +1,12 @@ -"""Gitea client (spec B2). +"""Gitea client (architecture D4, D5). Outbound: open PRs and post comments through a dedicated service account with a scoped token. Inbound is deliberately *polled* rather than webhook-driven, -so the agent VM stays outbound-only (B3), and intake is structured JSON — +so the agent VM stays outbound-only (D7), and intake is structured JSON — never routed through a chat bridge. ``find_pull_request`` is the external-truth query that makes opening a PR -idempotent across a crash: boot reconciliation (A3) asks Gitea whether the +idempotent across a crash: boot reconciliation (L8) asks Gitea whether the effect already happened rather than repeating it. """ @@ -84,7 +84,7 @@ class GiteaClient: return _pull_request(data) def find_pull_request(self, repo: str, head: str) -> PullRequest | None: - """External truth for A3: did the PR for this branch already get + """External truth for L8: did the PR for this branch already get opened? Includes closed PRs — a crash after opening must not be answered with "no PR is open, so open another". @@ -169,7 +169,7 @@ def _pull_request(data: dict[str, Any]) -> PullRequest: def pull_request_reconciler(client: GiteaClient, repo: str) -> Reconciler: - """Reconciler for an ``open_pr`` intent (A3 step 4). + """Reconciler for an ``open_pr`` intent (L8 boot step 2). An unreachable Gitea yields None — unverifiable, so the agent gates to the human rather than risking a duplicate PR. That is the conservative diff --git a/nisse-dev/src/nisse_dev/llm.py b/nisse-dev/src/nisse_dev/llm.py index 84fffb1..249ff28 100644 --- a/nisse-dev/src/nisse_dev/llm.py +++ b/nisse-dev/src/nisse_dev/llm.py @@ -1,11 +1,11 @@ -"""LLM access (spec A5, A11). +"""LLM access (architecture L9). One client interface, present from the first real LLM call. All traffic goes to the LiteLLM proxy — the VM's single LLM destination; the agent holds one LiteLLM token and no vendor credentials. The base URL and token come from the agent's config (nisse_dev.config), nothing hardcoded. -Model identity is expressed as roles (A11), resolved to concrete model strings +Model identity is expressed as roles (L9), resolved to concrete model strings from config; the resolved mapping is pinned at task start so a mid-task config change cannot split a task across two planners. """ @@ -34,7 +34,7 @@ class LLMError(RuntimeError): # A rate limit is a transient condition, not a bad request. Losing a # ten-turn delegation to one wastes far more tokens than waiting out the -# window, so retry once before surfacing it (A13's caps still bound the +# window, so retry once before surfacing it (L9's caps still bound the # total). RATE_LIMIT_STATUS = 429 RATE_LIMIT_RETRY_S = 20.0 @@ -71,7 +71,7 @@ class BudgetExhausted(UpstreamUnavailable): Distinct from the rest of its family because it will not clear on its own within the hour: an exhausted daily budget takes external models away until it resets, so retrying wastes time and the operator needs telling. - A5 keeps the budget itself at the proxy; recognising its refusal is ours. + L9 keeps the budget itself at the proxy; recognising its refusal is ours. """ @@ -120,7 +120,7 @@ class RoleMap: return str(getattr(self, role)) def pinned(self) -> dict[str, str]: - """The full resolved mapping, to record at task start (A11).""" + """The full resolved mapping, to record at task start (L9).""" return asdict(self) @@ -144,7 +144,7 @@ class LiteLLMClient: self._renderer = OpenAIChatRenderer() self._sleep = sleep self._max_tokens = max_tokens or DEFAULT_MAX_OUTPUT_TOKENS - # Set after each call so a caller can meter itself (A13 caps); + # Set after each call so a caller can meter itself (L9 caps); # a side channel rather than a return value, so the LLMClient # protocol stays the same for every implementation. self.last_usage: dict[str, int] = {} @@ -211,13 +211,13 @@ class LiteLLMClient: out: list[CanonicalEvent] = [] if message.get("content"): out.append(AssistantText(text=message["content"], model=produced_by)) - # Provider tool-call IDs are mapped at the boundary (A10): each call + # Provider tool-call IDs are mapped at the boundary (L7): each call # gets a locally-minted canonical ID; the provider's is dropped, since # every future request is rendered wholly from the canonical log. for call in message.get("tool_calls") or []: raw = call["function"]["arguments"] or "{}" # Weak models emit truncated or non-JSON argument strings; that is - # a bad model turn, not a client crash (A15). + # a bad model turn, not a client crash (L9). try: arguments = json.loads(raw) except json.JSONDecodeError as exc: @@ -244,10 +244,10 @@ class LiteLLMClient: class AnthropicPassthroughClient: - """LiteLLM's Anthropic passthrough (A5). Same interface as LiteLLMClient, + """LiteLLM's Anthropic passthrough (L9). Same interface as LiteLLMClient, so a caller chooses a route rather than a different kind of client. - Delegations take this path because fidelity matters there (A13); + Delegations take this path because fidelity matters there (L9); ask_expert and fallbacks use the normalized route, where it does not. """ @@ -340,7 +340,7 @@ class AnthropicPassthroughClient: out.append(AssistantText(text=block["text"], model=produced_by)) elif kind == "tool_use": # Provider ids are mapped at the boundary and never used as a - # join key (A10); the canonical log owns identity. + # join key (L7); the canonical log owns identity. out.append( ToolCall( call_id=mint_call_id(), @@ -349,7 +349,7 @@ class AnthropicPassthroughClient: ) ) # Vendor-opaque reasoning blocks (thinking, redacted_thinking) are - # deliberately dropped for now: A10 requires storing them tagged + # deliberately dropped for now: L7 requires storing them tagged # with the producing model and never replaying them outside the # session that made them, and the canonical schema has no event # for that yet. Nothing requests extended thinking, so none arrive. @@ -378,7 +378,7 @@ def _post_with_rate_limit_retry( raise AssertionError("unreachable") -# Model families we have a native renderer adapter for. A13: delegations take +# Model families we have a native renderer adapter for. L9: delegations take # the vendor's passthrough route because a normalising middlebox is lossy # exactly where a long-running delegation lives. def _response_cost(response: httpx.Response) -> float: diff --git a/nisse-dev/src/nisse_dev/main.py b/nisse-dev/src/nisse_dev/main.py index 6e2591c..7ee7e0c 100644 --- a/nisse-dev/src/nisse_dev/main.py +++ b/nisse-dev/src/nisse_dev/main.py @@ -1,7 +1,7 @@ """Entry point for the dev agent. Loads configuration, builds the agent, and stays up until told to stop. -Shutdown is deliberate rather than abrupt: A2's durability guarantees assume +Shutdown is deliberate rather than abrupt: L8's durability guarantees assume the process may die at any moment, but a clean stop still beats relying on them. """ diff --git a/nisse-dev/src/nisse_dev/matrix.py b/nisse-dev/src/nisse_dev/matrix.py index a5f114a..ca94ce9 100644 --- a/nisse-dev/src/nisse_dev/matrix.py +++ b/nisse-dev/src/nisse_dev/matrix.py @@ -1,4 +1,4 @@ -"""MatrixTransport (spec A1): Matrix as strictly a UI channel. +"""MatrixTransport (architecture L5): Matrix as strictly a UI channel. Implements the Transport interface directly over the Matrix client-server HTTP API (token auth, /sync long-poll, /send) with the httpx client already in the @@ -6,7 +6,7 @@ dependency tree — no Matrix SDK. The surface used is four endpoints; an SDK earns its keep when E2E arrives, and bot rooms are created unencrypted until then, so encrypted rooms are not supported here. -Inbound handling follows B2's polling posture: on homeserver errors the sync +Inbound handling follows L5's intake posture: on homeserver errors the sync loop backs off (capped) and retries indefinitely — an outage delays reactions but never fails anything. """ @@ -101,7 +101,7 @@ class MatrixTransport: def start(self) -> None: """Begin delivering new room messages to the registered callback. History from before start() is never delivered (Matrix room history - must not become load-bearing context, A1).""" + must not become load-bearing context, L5).""" if self._callback is None: raise TransportError("no message handler registered; call on_message first") self._stop.clear() @@ -184,7 +184,7 @@ class MatrixTransport: """Who a leading mention addresses, and the body without a leading mention of this agent. Only the start of the message counts: a mention later on ("approve, cc @alice") is part of the text. The - sender is never read from here; it comes from the event (A4).""" + sender is never read from here; it comes from the event (L3).""" body: str = content["body"] own = self._config.user_id if content.get("format") == "org.matrix.custom.html": diff --git a/nisse-dev/src/nisse_dev/oplog.py b/nisse-dev/src/nisse_dev/oplog.py index 6af9090..5b6606e 100644 --- a/nisse-dev/src/nisse_dev/oplog.py +++ b/nisse-dev/src/nisse_dev/oplog.py @@ -1,4 +1,4 @@ -"""Structured operational logging (spec A9). +"""Structured operational logging (architecture L12). One JSON object per line to stderr (journald captures it when running as a service). Every event carries the fixed envelope: ts, agent, event, task_id, @@ -16,7 +16,7 @@ class OpLog: def __init__(self, agent: str, stream: TextIO | None = None) -> None: self.agent = agent # Correlation ID for the enclosing unit of work; the orchestrator sets - # it when a unit starts and clears it when the unit ends (A9). + # it when a unit starts and clears it when the unit ends (L12). self.task_id: str | None = None self._stream = stream if stream is not None else sys.stderr diff --git a/nisse-dev/src/nisse_dev/planner.py b/nisse-dev/src/nisse_dev/planner.py index 3f7a8fe..2de895c 100644 --- a/nisse-dev/src/nisse_dev/planner.py +++ b/nisse-dev/src/nisse_dev/planner.py @@ -1,11 +1,11 @@ -"""LLM-backed planning agent (spec A4 gating, A11 roles, A13 plan quality). +"""LLM-backed planning agent (architecture L3 approvals, L9 roles, D2 plans). Gating is structural at the tool layer: the planner is offered exactly two tools — ``submit_plan``, which posts a plan for approval and ends the turn, and ``ask_human``, which pauses for an answer. No "proceed" tool exists, so the model has no path to advance itself past a gate. -Model output is appended to the canonical log as it arrives (A2/A10), so a +Model output is appended to the canonical log as it arrives (L8/L7), so a crash mid-planning leaves a truthful transcript. """ @@ -18,7 +18,9 @@ from nisse_dev.llm import BudgetExhausted, LLMClient, LLMError, RoleMap from nisse_dev.state import StateStore # Plans may be executed by a materially weaker model, which cannot -# reconstruct intent (A13). +# reconstruct intent. This predates the architecture's capability profiles +# and plans that specify rather than implement (L9, D2); Development Plan +# M2.3 replaces it. SYSTEM_PROMPT = """You are nisse-dev, a software engineering agent. You are planning, not implementing. Produce plans that are explicit and @@ -132,7 +134,7 @@ class LLMPlanningAgent: self, model: str, system: str ) -> tuple[SubmitPlan | AskHuman | None, str | None]: """A malformed or failed call is a bad turn, not a crash: the caller - retries once and then gates to the human (A12 — judgment about model + retries once and then gates to the human (L9 — judgment about model output is observable, never the model's own claim).""" try: return self._turn(model, system), None diff --git a/nisse-dev/src/nisse_dev/poller.py b/nisse-dev/src/nisse_dev/poller.py index c039d50..c0bd840 100644 --- a/nisse-dev/src/nisse_dev/poller.py +++ b/nisse-dev/src/nisse_dev/poller.py @@ -1,10 +1,10 @@ -"""Gitea comment intake (spec B2). +"""Gitea comment intake (architecture D5). -Polling, not webhooks: the agent VM stays outbound-only (B3), so nothing +Polling, not webhooks: the agent VM stays outbound-only (D7), so nothing needs to reach in. Intake is structured JSON straight from the API and never routed through a chat bridge. -Outage posture, per B2: back off exponentially, cap at a few minutes, retry +Outage posture, per L5: back off exponentially, cap at a few minutes, retry indefinitely, and log. Polling is pure intake — an outage delays a reaction but must never fail a task or force a state transition. """ diff --git a/nisse-dev/src/nisse_dev/publish.py b/nisse-dev/src/nisse_dev/publish.py index 96c764d..44763e4 100644 --- a/nisse-dev/src/nisse_dev/publish.py +++ b/nisse-dev/src/nisse_dev/publish.py @@ -1,4 +1,4 @@ -"""Publishing a finished milestone as a Gitea PR (spec B2, A2/A3). +"""Publishing a finished milestone as a Gitea PR (architecture D4, L8). The ordering here is the whole point. Intent is recorded *before* the first externally visible act, so a crash anywhere in the sequence leaves a durable @@ -164,8 +164,8 @@ def _pr_body(milestone: int, plan: str, service_url: str | None) -> str: plan, ] if service_url: - # B2: a runnable test service's address belongs in the PR description + # D4: a runnable test service's address belongs in the PR description # so a reviewer can exercise it. Nothing reports one yet; the browser - # tool (A6) is what will. + # tool (D7) is what will. parts += ["", "## Running test service", "", service_url] return "\n".join(parts) diff --git a/nisse-dev/src/nisse_dev/reconcile.py b/nisse-dev/src/nisse_dev/reconcile.py index 15b34eb..95c9900 100644 --- a/nisse-dev/src/nisse_dev/reconcile.py +++ b/nisse-dev/src/nisse_dev/reconcile.py @@ -1,4 +1,4 @@ -"""Boot-time reconciliation (spec A3): turn an ambiguous crash into a safe, +"""Boot-time reconciliation (architecture L8): turn an ambiguous crash into a safe, known state. Every pending intent found at startup is checked against external truth by a diff --git a/nisse-dev/src/nisse_dev/render.py b/nisse-dev/src/nisse_dev/render.py index 82df7a2..a293a6f 100644 --- a/nisse-dev/src/nisse_dev/render.py +++ b/nisse-dev/src/nisse_dev/render.py @@ -1,10 +1,10 @@ -"""A10 renderer: provider request bodies are built from the canonical log at +"""L7 renderer: provider request bodies are built from the canonical log at call time, never stored provider-shaped. First adapter: the OpenAI-compatible chat format LiteLLM's normalized route -speaks — the executor role's path (A5). The Anthropic passthrough adapter -arrives with delegations (A13). Token budgeting and prompt-cache breakpoints -are render-time concerns and are never stored (A10); neither exists yet in +speaks — the executor role's path (L9). The Anthropic passthrough adapter +arrives with delegations (L9). Token budgeting and prompt-cache breakpoints +are render-time concerns and are never stored (L7); neither exists yet in this skeleton. """ @@ -63,7 +63,7 @@ class OpenAIChatRenderer: _append_merged(messages, self._message_for(stored.event)) # Providers reject a tool call with no result following it. One # can legitimately be missing: a crash between the call and its - # result (A3), or a turn-ending gate tool. Render a placeholder so + # result (L8), or a turn-ending gate tool. Render a placeholder so # the transcript stays replayable rather than poisoned. if isinstance(stored.event, ToolCall) and ( stored.event.call_id not in answered diff --git a/nisse-dev/src/nisse_dev/render_anthropic.py b/nisse-dev/src/nisse_dev/render_anthropic.py index 88c1f6e..6f4e201 100644 --- a/nisse-dev/src/nisse_dev/render_anthropic.py +++ b/nisse-dev/src/nisse_dev/render_anthropic.py @@ -1,6 +1,6 @@ -"""Anthropic Messages renderer (spec A10, A13). +"""Anthropic Messages renderer (architecture L7, L9). -The second adapter, and the reason A13 puts normalisation in the renderer +The second adapter, and the reason L9 puts normalisation in the renderer rather than on the wire: the two vendors disagree *structurally*, not cosmetically. @@ -71,7 +71,7 @@ class AnthropicMessagesRenderer: blocks.extend(self._blocks_for(stored.event)) # A dangling tool_use is rejected outright by this API — more # strictly than the OpenAI shape — so a crash between a call and - # its result must not poison the transcript (A3). + # its result must not poison the transcript (L8). if isinstance(stored.event, ToolCall) and ( stored.event.call_id not in answered ): diff --git a/nisse-dev/src/nisse_dev/state.py b/nisse-dev/src/nisse_dev/state.py index 284ac4e..3187e96 100644 --- a/nisse-dev/src/nisse_dev/state.py +++ b/nisse-dev/src/nisse_dev/state.py @@ -1,4 +1,4 @@ -"""Durable state store (spec A2). +"""Durable state store (architecture L8). SQLite in WAL mode with ``synchronous=FULL``: every commit is fsynced, so a power cut mid-write can never produce torn state. Every state transition and @@ -127,7 +127,7 @@ class StateStore: migrations: Mapping[int, Migration] = MIGRATIONS, oplog: OpLog | None = None, ) -> None: - # Log emission is owned by the store (A9): the same code path that + # Log emission is owned by the store (L12): the same code path that # performs a durable write emits its log line, right after commit. self.oplog = oplog if oplog is not None else OpLog("nisse-dev") self._conn = sqlite3.connect(path, isolation_level=None) @@ -291,7 +291,7 @@ class StateStore: ] def append_event(self, event: CanonicalEvent) -> int: - """Append to the canonical transcript (A10); committed and fsynced + """Append to the canonical transcript (L7); committed and fsynced before return, like every other durable write.""" cursor = self._conn.execute( "INSERT INTO canonical_events (ts, type, payload) VALUES (?, ?, ?)", @@ -314,7 +314,7 @@ class StateStore: ] def mark_outcome(self, event_id: int, verdict: str, note: str = "") -> None: - """Record whether a delegation or consult was good (A16). + """Record whether a delegation or consult was good (L9). Kept out of the canonical log on purpose: the log is an append-only record of what happened, and a verdict is a later judgement about it diff --git a/nisse-dev/src/nisse_dev/tools.py b/nisse-dev/src/nisse_dev/tools.py index a450f07..a1e40c4 100644 --- a/nisse-dev/src/nisse_dev/tools.py +++ b/nisse-dev/src/nisse_dev/tools.py @@ -1,11 +1,11 @@ -"""Executor tools (spec A15: reduced schema, flat arguments). +"""Executor tools (architecture L9: reduced schema, flat arguments). -Deliberately unsandboxed. The VM is the trust boundary (B3) and tool use is -auto-approved inside an approved unit of work (A4), so per-command +Deliberately unsandboxed. The VM is the trust boundary (D7) and tool use is +auto-approved inside an approved unit of work (L3), so per-command confinement would add failure modes without adding safety. The workspace is where work happens — the default for relative paths and the command cwd — not a wall. Protecting agent state from the agent is a mount-layout concern -(A2/B3), not this module's job. +(L8/D7), not this module's job. What *is* enforced here is operational: commands cannot hang the loop, cannot consume the context window with megabytes of build log, and do not inherit @@ -153,7 +153,7 @@ class Workspace: def write_file(self, path: str, content: str) -> ToolOutcome: target = self._resolve(path) - # Not a sandbox — the VM is the trust boundary (B3). This is about the + # Not a sandbox — the VM is the trust boundary (D7). This is about the # work existing at all: a file written outside the repo is invisible # to git, so the milestone would publish an empty PR while the model # truthfully reports success. Models do guess plausible absolute paths diff --git a/nisse-dev/src/nisse_dev/transport.py b/nisse-dev/src/nisse_dev/transport.py index aca214d..6f5cfe4 100644 --- a/nisse-dev/src/nisse_dev/transport.py +++ b/nisse-dev/src/nisse_dev/transport.py @@ -1,4 +1,4 @@ -"""Transport interface (spec A1): all human-to-agent conversation. +"""Transport interface (architecture L5): all human-to-agent conversation. The orchestrator depends only on this interface; no platform SDK is imported anywhere outside a concrete implementation. MatrixTransport arrives as its own diff --git a/nisse-dev/src/nisse_dev/worker.py b/nisse-dev/src/nisse_dev/worker.py index 346c716..c6923e3 100644 --- a/nisse-dev/src/nisse_dev/worker.py +++ b/nisse-dev/src/nisse_dev/worker.py @@ -3,7 +3,7 @@ Planning turns, milestones and review responses can each run for minutes. Running them on the thread that delivered the message blocked that thread for as long: the Matrix sync loop read nothing new until the work finished, so an -operator's `stop` (A4) would only have arrived after the work it was meant to +operator's `stop` (L2) would only have arrived after the work it was meant to stop. Every handler now runs here instead, and transports only hand work over. Owning the store is the other half. SQLite connections are bound to the thread diff --git a/nisse-dev/tests/test_abandon.py b/nisse-dev/tests/test_abandon.py index 08716a8..f4543a7 100644 --- a/nisse-dev/tests/test_abandon.py +++ b/nisse-dev/tests/test_abandon.py @@ -1,4 +1,4 @@ -"""The `abandon` command: ending a task from any waiting state (A4, B2).""" +"""The `abandon` command: ending a task from any waiting state (L2).""" import io from pathlib import Path diff --git a/nisse-dev/tests/test_agent_build.py b/nisse-dev/tests/test_agent_build.py index 3e25d59..3fefb2c 100644 --- a/nisse-dev/tests/test_agent_build.py +++ b/nisse-dev/tests/test_agent_build.py @@ -137,8 +137,8 @@ def test_the_whole_graph_assembles(tmp_path: Path, origin: Path, monkeypatch) -> assert orchestrator._board is not None assert orchestrator._delegate is not None assert orchestrator._poller_factory is not None - assert orchestrator._executor._delegate is not None # A12 escalation path - assert orchestrator._executor._expert is not None # A14 consult path + assert orchestrator._executor._delegate is not None # L9 escalation path + assert orchestrator._executor._expert is not None # L9 consult path agent.stop() @@ -181,7 +181,7 @@ def test_poller_factory_ignores_the_agents_own_comments( def test_delegation_uses_the_passthrough_route( tmp_path: Path, origin: Path, monkeypatch ) -> None: - # A13: delegations run vendor-native; the normalized route is for the + # L9: delegations run vendor-native; the normalized route is for the # places where fidelity is cheap. from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient @@ -203,7 +203,7 @@ def test_httpx_clients_are_not_shared_between_routes( agent.stop() -# --- startup model discovery (A5/A11) ------------------------------------ +# --- startup model discovery (L9) ------------------------------------ def test_a_typod_role_refuses_to_boot(tmp_path: Path, monkeypatch) -> None: @@ -233,7 +233,7 @@ def test_a_role_this_key_cannot_reach_refuses_to_boot( def test_a_tool_less_model_warns_but_still_boots(tmp_path: Path, monkeypatch) -> None: - # A15 expects weak local models to be accommodated, and models LiteLLM + # L9 expects weak local models to be accommodated, and models LiteLLM # declares tool-less are observed driving tool loops anyway. import io @@ -285,7 +285,7 @@ def test_the_resolved_model_is_pinned_at_task_start( def test_delegation_records_what_the_alias_resolved_to( tmp_path: Path, origin: Path, monkeypatch ) -> None: - # A16 eval history keyed on an alias alone would average across whatever + # L9 eval history keyed on an alias alone would average across whatever # that alias pointed at over time. agent = build_offline(tmp_path, origin, monkeypatch) assert agent.orchestrator._delegate._backing_model == "anthropic/claude-opus-5" diff --git a/nisse-dev/tests/test_approval_binding.py b/nisse-dev/tests/test_approval_binding.py index af795f8..4124a93 100644 --- a/nisse-dev/tests/test_approval_binding.py +++ b/nisse-dev/tests/test_approval_binding.py @@ -1,4 +1,4 @@ -"""Approvals and feedback bind to the plan revision they were about (A4).""" +"""Approvals and feedback bind to the plan revision they were about (L3).""" import io from pathlib import Path diff --git a/nisse-dev/tests/test_approvals.py b/nisse-dev/tests/test_approvals.py index 0aa5032..58c6d9b 100644 --- a/nisse-dev/tests/test_approvals.py +++ b/nisse-dev/tests/test_approvals.py @@ -1,5 +1,5 @@ """Plan externalisation to Gitea and approvals arriving on either channel -(spec A4, Part H).""" +(architecture L3, D2).""" import io from pathlib import Path @@ -96,8 +96,8 @@ def test_project_start_opens_an_issue_and_posts_the_plan(tmp_path: Path) -> None issue, body = gitea.comments[0] assert issue == 1 assert "plan 1" in body - assert "approve rev-1" in body # the tag is printed for copy-paste (A4) - # The store keeps a pointer, not the plan record itself (Part H). + assert "approve rev-1" in body # the tag is printed for copy-paste (L3) + # The store keeps a pointer, not the plan record itself (D2). assert store.get_state().data["board"]["issue"] == 1 @@ -136,7 +136,7 @@ def test_non_operator_gitea_comment_cannot_approve(tmp_path: Path) -> None: def test_agents_own_comment_cannot_approve_its_plan(tmp_path: Path) -> None: # The agent posts plans as nisse-dev-testing; that login is not on the - # operator allowlist, so its own comments can never satisfy its gate (A4). + # operator allowlist, so its own comments can never satisfy its gate (L3). gitea = FakeGitea() orch, store, transport, _ = build(tmp_path, gitea) transport.receive("don", "use demo") diff --git a/nisse-dev/tests/test_canonical.py b/nisse-dev/tests/test_canonical.py index da45109..930982b 100644 --- a/nisse-dev/tests/test_canonical.py +++ b/nisse-dev/tests/test_canonical.py @@ -77,7 +77,7 @@ def test_unknown_fields_rejected() -> None: # The v1 schema as it shipped (PR #2), frozen here to prove a newer image -# opens and upgrades a database written by the older one (A2). +# opens and upgrades a database written by the older one (L8). V1_SCHEMA = [ "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", """ diff --git a/nisse-dev/tests/test_catalog.py b/nisse-dev/tests/test_catalog.py index 7021639..e502d4f 100644 --- a/nisse-dev/tests/test_catalog.py +++ b/nisse-dev/tests/test_catalog.py @@ -1,4 +1,4 @@ -"""Runtime model discovery (spec A5/A11/A13). +"""Runtime model discovery (architecture L9). The point of these tests is that nothing here reads the model's *name*. A semantic alias like "remote-architecture" is free to point anywhere, so the @@ -78,7 +78,7 @@ def test_a_semantic_alias_routes_by_its_provider() -> None: def test_the_same_alias_reroutes_when_it_is_repointed() -> None: - # A13: provisionally accept LiteLLM's normalization for a vendor with no + # L9: provisionally accept LiteLLM's normalization for a vendor with no # native adapter, rather than sending it Anthropic-shaped bodies. cat = catalog(entry("remote-architecture", "openai/gpt-6-astra", "openai")) normalized, passthrough = object(), object() @@ -189,7 +189,7 @@ def test_discovery_asks_the_proxy_with_the_token() -> None: def test_pinning_records_what_the_alias_resolved_to() -> None: - # An alias may point somewhere else tomorrow; eval history (A16) keyed on + # An alias may point somewhere else tomorrow; eval history (L9) keyed on # the alias alone would average across whatever it pointed at. cat = catalog(entry("remote-architecture", "anthropic/claude-opus-5", "anthropic")) pinned = cat.pinned({"planner": "remote-architecture"}) diff --git a/nisse-dev/tests/test_config.py b/nisse-dev/tests/test_config.py index eeb710a..ca0a1b1 100644 --- a/nisse-dev/tests/test_config.py +++ b/nisse-dev/tests/test_config.py @@ -281,7 +281,7 @@ def test_the_example_file_is_valid() -> None: def test_state_db_and_workspace_are_separate() -> None: - # A2: build artefacts must not share a volume with agent state. + # L8: build artefacts must not share a volume with agent state. config = build() assert config.state_db.parent != config.workspace diff --git a/nisse-dev/tests/test_context.py b/nisse-dev/tests/test_context.py index 5da70a1..463afa7 100644 --- a/nisse-dev/tests/test_context.py +++ b/nisse-dev/tests/test_context.py @@ -1,4 +1,4 @@ -"""The context budget (spec A10).""" +"""The context budget (architecture L6).""" import io from pathlib import Path diff --git a/nisse-dev/tests/test_control.py b/nisse-dev/tests/test_control.py index 68e0f71..02cd139 100644 --- a/nisse-dev/tests/test_control.py +++ b/nisse-dev/tests/test_control.py @@ -180,7 +180,7 @@ def test_ask_human_pauses_and_answer_resumes(tmp_path: Path) -> None: assert "monorepo" in h.transport.outbound[-1].text -# --- restarts (A3 steps 7 and 8) -------------------------------------------- +# --- restarts (L8 boot steps 4 and 5) -------------------------------------------- class FailsOnce: diff --git a/nisse-dev/tests/test_delegation.py b/nisse-dev/tests/test_delegation.py index 4a55390..ed2f49d 100644 --- a/nisse-dev/tests/test_delegation.py +++ b/nisse-dev/tests/test_delegation.py @@ -121,7 +121,7 @@ def test_delegation_works_on_its_own_branch( def test_abort_discards_the_branch_and_its_work( store: StateStore, ws: Workspace, repo: GitRepo ) -> None: - # A13: abort must leave nothing to resume on top of. + # L9: abort must leave nothing to resume on top of. client = ScriptedClient( [ [call("write_file", path="half-done.py", content="oops")], @@ -138,7 +138,7 @@ def test_abort_discards_the_branch_and_its_work( def test_stop_discards_the_branch_and_settles_the_intent( store: StateStore, ws: Workspace, repo: GitRepo ) -> None: - # A4 stop is treated like a failure, not like an outage: the operator + # L2's stop is treated like a failure, not like an outage: the operator # stopped it for a reason, so nothing is kept for resuming. cancel = Cancel() client = ScriptedClient( @@ -213,7 +213,7 @@ def test_cap_breach_is_not_usable_and_discards_work( brief="b", branch="d" ) - assert not result.usable # the caller must gate, not resume (A13) + assert not result.usable # the caller must gate, not resume (L9) assert not (repo.root / "partial.py").exists() @@ -251,7 +251,7 @@ def test_composite_events_record_the_brief_caps_and_outcome( assert started.brief == "the brief" assert started.model == "claude-haiku-4-5" assert started.caps == {"turns": 7, "tokens": 1234, "wall_clock_s": 42.0} - # A16: this pair is the eval record, so it must carry the outcome. + # L9: this pair is the eval record, so it must carry the outcome. assert completed.exit_status == "handback" assert "greet()" in completed.handback @@ -259,7 +259,7 @@ def test_composite_events_record_the_brief_caps_and_outcome( def test_hand_back_tool_is_offered_alongside_the_executor_tools() -> None: names = [t["function"]["name"] for t in DELEGATE_TOOLS] assert names[-1] == "hand_back" - assert "run_command" in names # full tool access, not a consult (A13) + assert "run_command" in names # full tool access, not a consult (L9) def test_malformed_handback_is_rejected_and_the_delegation_continues( @@ -348,7 +348,7 @@ def test_failure_may_be_reported_without_doing_work( assert len(client.calls) == 1 # believed immediately, not challenged -# --- A3 step 5: the resume rule ----------------------------------------- +# --- L8 boot step 5: the resume rule ----------------------------------------- def pending_delegation_intent(store: StateStore, branch: str = "d") -> Any: @@ -446,7 +446,7 @@ def test_resume_continues_the_same_delegation( store: StateStore, ws: Workspace, repo: GitRepo ) -> None: # Interrupt after one turn, then resume: no second delegation_started, - # because A10 defines a session as the delegation, not the process. + # because L7 defines a session as the delegation, not the process. repo.checkout_new_branch("d") store.append_event( DelegationStarted(brief="b", role="delegate", model="m", caps={}) @@ -497,7 +497,7 @@ def test_recover_resumes_a_clean_cut_with_a_notice( result = build(client, store, ws, repo).recover(notices.append) assert result is not None and result.usable - # A3: a notice, not a question — a clean cut needs no operator decision. + # L8: a notice, not a question — a clean cut needs no operator decision. assert notices and "resuming the delegation" in notices[0] @@ -550,7 +550,7 @@ def test_recover_leaves_an_unclean_cut_for_the_boot_gate( assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT] -# --- the token cap, which A13 calls hard and was decoration ------------- +# --- the token cap, which L9 calls hard and was decoration ------------- class MeteredClient(ScriptedClient): @@ -599,7 +599,7 @@ def test_tokens_spent_are_reported_not_left_at_zero( completed = next( e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted) ) - # A16 evaluates cost against outcome, which needs a real number. + # L9 evaluates cost against outcome, which needs a real number. assert completed.tokens == 2000 @@ -639,7 +639,7 @@ def test_budget_exhaustion_preserves_the_work_for_later( # The work survives, on its branch. assert repo.current_branch() == "d" assert (repo.root / "progress.py").exists() - # And the intent stays pending, which is what A3's resume rule keys on. + # And the intent stays pending, which is what L8's resume rule keys on. assert [i.kind for i in store.pending_intents()] == [DELEGATION_INTENT] @@ -647,7 +647,7 @@ def test_an_ordinary_failure_still_discards( store: StateStore, ws: Workspace, repo: GitRepo ) -> None: # Only external interruptions are preserved; a delegate that made a mess - # is still cleaned up (A13). + # is still cleaned up (L9). client = ScriptedClient([[hand_back(succeeded=False, changed="cannot")]]) result = build(client, store, ws, repo).run(brief="b", branch="d") diff --git a/nisse-dev/tests/test_evals.py b/nisse-dev/tests/test_evals.py index 3336990..6f09c56 100644 --- a/nisse-dev/tests/test_evals.py +++ b/nisse-dev/tests/test_evals.py @@ -54,7 +54,7 @@ def test_pairs_a_start_with_its_completion(store: StateStore) -> None: def test_unpaired_start_is_skipped(store: StateStore) -> None: # A crash mid-delegation leaves a start with no completion; a half-formed - # record would pollute the set A16 exists to make trustworthy. + # record would pollute the set L9 exists to make trustworthy. store.append_event( DelegationStarted(brief="died", role="delegate", model="m", caps={}) ) diff --git a/nisse-dev/tests/test_executor.py b/nisse-dev/tests/test_executor.py index 846721a..df024c1 100644 --- a/nisse-dev/tests/test_executor.py +++ b/nisse-dev/tests/test_executor.py @@ -317,7 +317,7 @@ def test_ask_expert_is_offered_only_when_a_desk_exists( names = [t["function"]["name"] for t in client2.calls[0]["tools"]] assert "ask_expert" in names assert names[-1] == "finish" - # A12: in addition to the observable triggers, not instead of them. + # L9: in addition to the observable triggers, not instead of them. assert "run_command" in names diff --git a/nisse-dev/tests/test_expert.py b/nisse-dev/tests/test_expert.py index 0b5a23b..5434556 100644 --- a/nisse-dev/tests/test_expert.py +++ b/nisse-dev/tests/test_expert.py @@ -109,7 +109,7 @@ def test_consult_is_stateless_and_uses_the_consultant_role( desk(client, store).ask("Is this API deprecated?") call = client.calls[0] - # A14: no repo access, no session state — exactly the question, and none + # L9: no repo access, no session state — exactly the question, and none # of the log the caller was working from. assert [e.event.text for e in call["events"]] == ["Is this API deprecated?"] assert all("secret repo context" not in e.event.text for e in call["events"]) @@ -131,7 +131,7 @@ def test_retry_succeeds_after_a_bad_first_reply(store: StateStore) -> None: def test_consults_are_recorded_as_composite_events(store: StateStore) -> None: - # A16 wants a consult/delegation eval set; reusing the pair gets consult + # L9 wants a consult/delegation eval set; reusing the pair gets consult # evaluation for free. desk(ScriptedClient([GOOD]), store).ask("q") events = [e.event for e in store.read_events()] @@ -183,4 +183,4 @@ def test_budget_counts_only_after_the_task_marker(store: StateStore) -> None: def test_default_budget_is_small() -> None: - assert DEFAULT_BUDGET <= 5 # A14: do not let this become a second subsystem + assert DEFAULT_BUDGET <= 5 # L9: do not let this become a second subsystem diff --git a/nisse-dev/tests/test_gates.py b/nisse-dev/tests/test_gates.py index edd2544..240e14d 100644 --- a/nisse-dev/tests/test_gates.py +++ b/nisse-dev/tests/test_gates.py @@ -44,7 +44,7 @@ def test_unknown_sender_is_silently_ignored() -> None: def test_approve_with_trailing_words_is_rejection() -> None: - # "approve, but rename the module" is feedback, not approval (A4: the + # "approve, but rename the module" is feedback, not approval (L3: the # typed word approve, nothing else). result = gate().evaluate("don", "approve, but rename the module", "transport") assert isinstance(result, Rejected) @@ -69,7 +69,7 @@ def test_a_sentence_containing_approve_is_still_feedback() -> None: assert result.feedback == "I approve of most of it" -# --- binding by arrival (A4): a reply that names no revision is about the one +# --- binding by arrival (L3): a reply that names no revision is about the one # that was up when it arrived. diff --git a/nisse-dev/tests/test_gitea.py b/nisse-dev/tests/test_gitea.py index 3a35362..f30e251 100644 --- a/nisse-dev/tests/test_gitea.py +++ b/nisse-dev/tests/test_gitea.py @@ -163,7 +163,7 @@ def test_list_comments_passes_since() -> None: assert seen["since"] == "2026-09-07T00:00:00Z" -# --- reconciler (A3 external truth) -------------------------------------- +# --- reconciler (L8 external truth) -------------------------------------- def test_reconciler_reports_existing_pr_as_done() -> None: diff --git a/nisse-dev/tests/test_llm.py b/nisse-dev/tests/test_llm.py index ffc4580..970d226 100644 --- a/nisse-dev/tests/test_llm.py +++ b/nisse-dev/tests/test_llm.py @@ -360,7 +360,7 @@ def test_budget_refusal_is_a_kind_of_upstream_trouble() -> None: assert issubclass(BudgetExhausted, UpstreamUnavailable) -# --- tool_choice (A15: constrained decoding) -------------------------------- +# --- tool_choice (L9: constrained decoding) -------------------------------- TOOLS = [ { diff --git a/nisse-dev/tests/test_oplog.py b/nisse-dev/tests/test_oplog.py index 5759ff3..4e1404b 100644 --- a/nisse-dev/tests/test_oplog.py +++ b/nisse-dev/tests/test_oplog.py @@ -1,4 +1,4 @@ -"""State-change log conventions (spec A9): every durable mutation emits one +"""State-change log conventions (architecture L12): every durable mutation emits one JSON-lines event with the fixed envelope, from the store's own write path.""" import io diff --git a/nisse-dev/tests/test_poller.py b/nisse-dev/tests/test_poller.py index 4e94c9d..f67d261 100644 --- a/nisse-dev/tests/test_poller.py +++ b/nisse-dev/tests/test_poller.py @@ -88,7 +88,7 @@ def test_outage_does_not_raise_out_of_the_loop() -> None: time.sleep(0.05) poller.stop() - # It recovered and delivered rather than dying on the outage (B2: an + # It recovered and delivered rather than dying on the outage (L5: an # outage delays a reaction, never fails a task). assert [c.id for c in seen] == [1] assert "gitea.poll_failed" in stream.getvalue() @@ -118,7 +118,7 @@ def test_backoff_grows_then_caps() -> None: delays = [json.loads(line)["retry_in_s"] for line in stream.getvalue().splitlines()] assert delays[:4] == [0.001, 0.002, 0.004, 0.008] - assert max(delays) == 0.008 # capped, and retried indefinitely (B2) + assert max(delays) == 0.008 # capped, and retried indefinitely (L5) assert MAX_BACKOFF_S <= 300.0 # the shipped cap is "a few minutes" diff --git a/nisse-dev/tests/test_pr_open.py b/nisse-dev/tests/test_pr_open.py index 63a1718..c29aaab 100644 --- a/nisse-dev/tests/test_pr_open.py +++ b/nisse-dev/tests/test_pr_open.py @@ -1,4 +1,4 @@ -"""The B1 loop past EXECUTING: publish, review gate, then next milestone or +"""The loop past EXECUTING (D4, D5): publish, review gate, then next milestone or project completion.""" import io diff --git a/nisse-dev/tests/test_render.py b/nisse-dev/tests/test_render.py index dc71534..bf8ec85 100644 --- a/nisse-dev/tests/test_render.py +++ b/nisse-dev/tests/test_render.py @@ -74,7 +74,7 @@ def test_compaction_replaces_its_range_only() -> None: def test_tool_call_without_a_result_gets_a_placeholder() -> None: - # Providers reject a dangling tool_use; this happens after a crash (A3) + # Providers reject a dangling tool_use; this happens after a crash (L8) # or with turn-ending gate tools like submit_plan. events = stored( [ diff --git a/nisse-dev/tests/test_review_loop.py b/nisse-dev/tests/test_review_loop.py index 2cc67c5..9a3f5c3 100644 --- a/nisse-dev/tests/test_review_loop.py +++ b/nisse-dev/tests/test_review_loop.py @@ -1,4 +1,4 @@ -"""Reacting to PR review comments while in PR_OPEN (spec B2).""" +"""Reacting to PR review comments while in PR_OPEN (architecture D5).""" import io from pathlib import Path diff --git a/nisse-dev/tests/test_stop.py b/nisse-dev/tests/test_stop.py index 0856f8d..4137600 100644 --- a/nisse-dev/tests/test_stop.py +++ b/nisse-dev/tests/test_stop.py @@ -1,4 +1,4 @@ -"""The operator's kill switch, `stop` (spec A4).""" +"""The operator's kill switch, `stop` (architecture L2).""" import io import threading diff --git a/nisse-dev/tests/test_tools.py b/nisse-dev/tests/test_tools.py index 18b14f1..ac8b53c 100644 --- a/nisse-dev/tests/test_tools.py +++ b/nisse-dev/tests/test_tools.py @@ -13,7 +13,7 @@ def ws(tmp_path: Path) -> Workspace: def test_manifest_is_small_and_flat() -> None: - # A15: fewer tools, flatter arguments for the local executor. + # L9: fewer tools, flatter arguments for the local executor. names = [t["function"]["name"] for t in EXECUTOR_TOOLS] assert names == ["read_file", "write_file", "list_dir", "run_command"] for tool in EXECUTOR_TOOLS: @@ -62,7 +62,7 @@ def test_run_command_success_reports_exit_code_and_stdout(ws: Workspace) -> None def test_nonzero_exit_is_a_result_not_an_error(ws: Workspace) -> None: outcome = ws.run_command("exit 3") - assert not outcome.success # feeds the A12 failure count + assert not outcome.success # feeds the L9 failure count assert "exit code: 3" in outcome.result diff --git a/nisse-ops/CLAUDE.md b/nisse-ops/CLAUDE.md index 1082c1e..03e9f75 100644 --- a/nisse-ops/CLAUDE.md +++ b/nisse-ops/CLAUDE.md @@ -1,10 +1,10 @@ # nisse-ops -The Sysadmin Agent (Part C of the Nisse system). A thin application over the shared +The Sysadmin Agent (Part O of the Nisse architecture). A thin application over the shared `nisse-core` package. **Read the architecture before making design decisions here:** -`docs/Architecture-Build-Spec.md` at the repo root. Part C specifies this agent; -Part A specifies the library contracts it depends on. Requirements that look like +`docs/Architecture.md` at the repo root. Part O specifies this agent; +Part L specifies the library contracts it depends on. Requirements that look like free choices in this package — the permission policy engine, confirm-first gating, secrets and SSO handling — are usually settled there. diff --git a/nisse-ops/README.md b/nisse-ops/README.md index fa1a0de..6b01ddb 100644 --- a/nisse-ops/README.md +++ b/nisse-ops/README.md @@ -4,5 +4,5 @@ The Sysadmin Agent: operates and maintains infrastructure, driven by Vikunja tickets, gated on high-consequence actions. A thin application over the shared `nisse-core` library. Built after the Dev Agent works. -**Part C** of the specification at [`../docs/Architecture-Build-Spec.md`](../docs/Architecture-Build-Spec.md) -covers this agent; **Part A** covers the library it sits on. +**Part O** of the architecture at [`../docs/Architecture.md`](../docs/Architecture.md) +covers this agent; **Part L** covers the library it sits on. diff --git a/specs/orchestrator.qnt b/specs/orchestrator.qnt index fcf9385..69c6145 100644 --- a/specs/orchestrator.qnt +++ b/specs/orchestrator.qnt @@ -56,7 +56,7 @@ module orchestrator { task: bool, // a project is in use gate: Gate, revs: str -> int, // latest revision per gate - lastRev: int, // revisions run across the task (A4) + lastRev: int, // revisions run across the task (L3) ms: int, pending: Pending, prOn: bool, prKey: int, prLast: int, // _data["pr"] @@ -270,7 +270,7 @@ module orchestrator { else if (s.st == "AWAIT_INPUT" and s.pending.kind != "") s0 else if (s.st == "EXECUTING" and giteaUp and prState(s, prKeyOf(s)) == "open") - // A3 step 4: the milestone's PR is open, so it was published and only + // L8 boot step 3: the milestone's PR is open, so it was published and only // PR_OPEN went unrecorded. Adopt it; nothing watched it, so from 0. { ...s0, st: "PR_OPEN", prOn: true, prKey: prKeyOf(s), prLast: 0, prWatch: { on: true, floor: 0 }, planWatch: OFF } diff --git a/tests/integration/test_crash_durability.py b/tests/integration/test_crash_durability.py index f80bb13..a0aaeda 100644 --- a/tests/integration/test_crash_durability.py +++ b/tests/integration/test_crash_durability.py @@ -1,5 +1,5 @@ """Integration: state written before an abrupt process death must be intact -and the in-flight intent visible on the next boot (spec A2/A3).""" +and the in-flight intent visible on the next boot (architecture L8).""" import subprocess import sys diff --git a/tests/integration/test_expert_live.py b/tests/integration/test_expert_live.py index 0205152..39856b3 100644 --- a/tests/integration/test_expert_live.py +++ b/tests/integration/test_expert_live.py @@ -1,4 +1,4 @@ -"""A real consult over the normalized route (A14). +"""A real consult over the normalized route (L9). The point is the prompt-level contract: a live model must produce something that parses against our local schema, since no API-level enforcement exists. @@ -41,7 +41,7 @@ def test_live_consult_parses_against_the_local_schema(desk) -> None: assert answer.confidence in {"high", "medium", "low"} assert len(answer.answer) > 20 - # The consult is captured for A16 without any extra bookkeeping. + # The consult is captured for L9 without any extra bookkeeping. [record] = collect(store) assert record.role == "consultant" assert record.exit_status == "answered" diff --git a/tests/integration/test_gate_restart.py b/tests/integration/test_gate_restart.py index 16b2a02..f3e6d8f 100644 --- a/tests/integration/test_gate_restart.py +++ b/tests/integration/test_gate_restart.py @@ -1,6 +1,6 @@ """Integration: a restart mid-gate resumes the open gate with its revision history intact; unresolved intents gate the whole boot to AWAIT_INPUT -(spec A2/A3/A4 together).""" +(architecture L8 and L3 together).""" import io from pathlib import Path diff --git a/tests/integration/test_gitea_live.py b/tests/integration/test_gitea_live.py index 27270ab..10af6a5 100644 --- a/tests/integration/test_gitea_live.py +++ b/tests/integration/test_gitea_live.py @@ -1,5 +1,5 @@ """Live Gitea round trip against the sandbox repo: push a branch, open a PR, -comment, and prove the A3 reconciler reads external truth correctly. +comment, and prove the L8 reconciler reads external truth correctly. Cleans up after itself (closes the PR, deletes the branch) so repeated runs do not silt up the sandbox. @@ -74,7 +74,7 @@ def test_push_open_pr_and_reconcile( # Now the same reconciler must see the effect and refuse to repeat it. assert reconcile(intent_for(branch)) is True - # A4 is only real if the server enforces it. The agent's own token + # D4 is only real if the server enforces it. The agent's own token # must not be able to merge the PR it just opened, and must not be # able to push to the protected default branch at all. merged = client._http.post( diff --git a/tests/integration/test_model_capability.py b/tests/integration/test_model_capability.py index 6411177..3703cbe 100644 --- a/tests/integration/test_model_capability.py +++ b/tests/integration/test_model_capability.py @@ -1,9 +1,9 @@ -"""Capability matrix across models (spec Part H, A15). +"""Capability matrix across models (architecture L9, Q). -Part H says the thresholds that depend on local capability — A12's failure -count, A14's consult budget, A15's manifest size — must be tuned "at -build-order step 5 on the real hardware". This is the measurement that makes -that possible, rather than reasoning about model strength from reputation. +The thresholds that depend on local capability (L9: the failure count, the +consult budget, the manifest size) are tuned on the real hardware. This is the +measurement that makes that possible, rather than reasoning about model +strength from reputation. What it measures is the thing that actually breaks: whether a model can hold the tool-call contract our loops depend on. llama3.2 could not — it returned @@ -102,7 +102,7 @@ def test_model_answers_at_all(model: str, catalog: ModelCatalog, routes) -> None def test_model_holds_the_planner_contract( model: str, catalog: ModelCatalog, routes ) -> None: - """A4's gating is structural at the tool layer: a model that answers in + """L3's approvals are structural at the tool layer: a model that answers in prose cannot be gated, because there is no plan to approve.""" normalized, passthrough = routes client = select_client(model, normalized, passthrough, catalog) @@ -161,7 +161,7 @@ def test_model_round_trips_a_tool_result( def test_model_holds_the_prompt_level_json_contract( model: str, catalog: ModelCatalog, routes ) -> None: - """A14 contracts structured output at the prompt level because vendors + """L9 contracts structured output at the prompt level because vendors differ on JSON enforcement. This measures whether that holds per model.""" normalized, passthrough = routes client = select_client(model, normalized, passthrough, catalog) diff --git a/tests/integration/test_passthrough_live.py b/tests/integration/test_passthrough_live.py index e5d0812..2e7c949 100644 --- a/tests/integration/test_passthrough_live.py +++ b/tests/integration/test_passthrough_live.py @@ -1,7 +1,7 @@ """Live Anthropic passthrough: the vendor-native route delegations will use. Proves the renderer's body is accepted as-is, that a tool call round-trips -through the canonical log, and — per A5 — that prompt caching survives the +through the canonical log, and — per L9 — that prompt caching survives the passthrough rather than being flattened by the proxy. """ @@ -75,7 +75,7 @@ def test_passthrough_round_trips_a_tool_call( def test_prompt_caching_survives_the_passthrough( live_config: AgentConfig, live_model: str ) -> None: - # A5 asks for this to be verified rather than assumed: a proxy that + # L9 asks for this to be verified rather than assumed: a proxy that # flattened the request would silently drop cache control and turn a # cost optimisation into a per-call expense. base = live_config.llm_base_url.rstrip("/") diff --git a/tests/integration/test_planning_live.py b/tests/integration/test_planning_live.py index 821ccec..65d8c30 100644 --- a/tests/integration/test_planning_live.py +++ b/tests/integration/test_planning_live.py @@ -1,8 +1,8 @@ """Live planning turn: a real model must answer with a submit_plan or ask_human tool call, not prose. -Plans with the test config's planner role. Planning escalates to a frontier -model unconditionally (A12), and small local models do not hold the tool-call +Plans with the test config's planner role. The planner role is a frontier +model today (Development Plan §2), and small local models do not hold the tool-call contract reliably, so that role must be one that does. """ @@ -43,5 +43,5 @@ def test_live_planning_turn_returns_a_tool_action( assert isinstance(action, SubmitPlan | AskHuman) text = action.content if isinstance(action, SubmitPlan) else action.question assert text.strip(), "tool call carried empty content" - # The turn must be durable regardless of what the model said (A2/A10). + # The turn must be durable regardless of what the model said (L8/L7). assert len(store.read_events()) > 1