Commit Graph
21 Commits
Author SHA1 Message Date
dongarandClaude Opus 5.5 a921b73d34 M4.4: an unreachable build VM blocks its projects until it's back (#137)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m43s
CI / e2e (pull_request) Successful in 3m53s
M4.3's block, with new triggers and a way out. A VM out of reach at the
check before a run, in a run (which ends there: the driver, a toolchain
step and a requested step), or while preparing the workspace blocks every
project on it; the item stays ready, waiting on no one. On the intake round
it is tried again every tuning.build_vm.retry_s (300), and once it answers
it must still answer settle_s (180) later; then the work a lost run left
there is parked (pushed), the VM unblocked, and that said once.

A changed host key, at the check or on a retry, blocks the VM too, until
the operator's `vm ready`, which trusts the new key: `trust new host key`
is retired, and the message says it's a key problem that may be an
impersonation. Questions are answered while a VM is blocked, without it.
Starting the agent with a VM down no longer fails.

The walk breaks the VM's SSH, now or partway through a run, and checks that
no model works on a VM that is down; loop.qnt models the VM going and
coming back (unblocked_only_when_up). Together they found: a lost run's
work set aside by the next item there; a question left behind a block; a
lost VM swallowed while the toolchain step checked or recorded it; and a
remark waiting to be answered that outlived its PR's merge. All fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-08 21:06:05 +00:00
dongarandClaude Opus 5.5 9968a0922a M4.1: toolchain preflight
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m59s
CI / e2e (pull_request) Successful in 10m33s
A model works out and sets up what a project needs on its build VM;
code makes sure that happens, bounds it and checks the result (D3).

- Before each driver run on a project, one round trip checks the build
  VM: the harness's own baseline (missing: the item waits, a template
  problem), the workspace clone (missing: cloned again and said; the
  item resumes from what it pushed) and a per-project marker.
- No marker (a new project, a new or wiped VM): the item's driver model
  runs a bounded preflight first, with its own prompt, the execution
  tools (screened as ever) and toolchain_ready. The harness runs each
  reported version command itself (version-shaped commands only, even
  from the marker, which the model's commands could edit) and writes
  the marker. Gaps and an unfinished preflight are facts and work goes
  on; the thread hears only of installs and gaps. A rebuilt VM starts
  from the last toolchain.
- Later runs check the versions again; a change is a fact and a thread
  line. The PR body says what the work was tested with.
- A changed host key is its own failure (HostKeyChanged): the item
  waits for the operator's `trust new host key`, which drops the pin;
  then the VM is set up again.
- A prompt line: install what the work needs on the build VM, say why,
  and put lasting needs in the project's own pins.
- tuning.preflight: max_turns, command_timeout_s.
- The property walk gains a VM wipe and ready_before_work (checked
  against a mutant that skips preflight); parked_work_safe accepts that
  a wipe loses what wasn't pushed (D3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 15:43:30 +00:00
dongarandClaude Opus 5.5 ab469b60bb M3.3: the trust dial (the agent merges approved PRs)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 11m23s
A project's trust.merge is operator (the default) or approved (L3, D5,
#65). Set to approved, the harness, never the model, merges a PR in the
PR watch once an approving review from a gitea.operators account is on
its current commit, its checks pass (none at all counts; pending
waits) and it's mergeable; the item then goes on as after any merge.
Each reason to hold off (failed checks, conflicts, a refused merge) is
said once per commit.

Gitea enforces it: protection requires approvals from an operators-only
whitelist and dismisses stale ones. At start, for each approved
project, the agent checks by behaviour that its own unapproved merge is
refused, with a throwaway branch and PR made through the API (never the
workspace); if it isn't refused, the operator merges until a restart.

- gitea: approvals, combined check status, a PR's head commit and
  mergeability, API-made branches and their deletion.
- Test and bring-up repositories get the protection.
- Tests: the decision (unit), the hard wall on a real Gitea
  (test_trust_live), an e2e test where the operator only approves, and
  a walk property, merges_only_approved, checked against a mutant that
  ignores which commit was approved.

Closes #65

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 13:48:16 +00:00
dongarandClaude Opus 5.5 6ae2375194 M2.8: switch over to the new loop
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m16s
CI / e2e (pull_request) Successful in 10m51s
nisse-dev now runs only the conversational loop. The gated state machine
it was built alongside is gone:

- Removed control.py, planner.py, executor.py, approvals.py, gates.py
  (command_form moves to loop/reader.py), publish.py and poller.py, the
  old assembly in agent.py (its shared helpers move to loop/build.py),
  the store's state-machine view (get_state, transition), their tests,
  the old end-to-end and live planning/milestone/gate tests, and
  specs/orchestrator.qnt with its scenarios.
- Roles are driver, helper, expert and classifier; driver is required.
  loop:, roles.planner, roles.executor and tuning.executor are refused
  with what to use instead; tuning.driver takes the turn cap and failure
  limit.
- The benchmark and the capability matrix measure the driver: the
  loop's own system prompt and tools, the task's plan as the agreed
  restatement. Task plans end with finish_milestone, so earlier runs
  show as (old); stored runs' executor tuning reads as the driver's.
- A delegation on the loop records what its helper's alias resolved to
  (L9), as the old path's did.
- The dump shows a work item's revision and what it waits for.
- Behaviour the old tests covered and the loop shares stays covered:
  driver runs that fail on a model call, stop mid-turn, reset the
  failure count; go-aheads ignoring case and punctuation; a request too
  big to fit is never sent; the assembly tests, now on build_loop_agent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 03:12:58 +00:00
dongarandClaude Opus 5.5 59fd00ce8c Roles, capability profiles and help on the new loop (M2.6)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m23s
CI / e2e (pull_request) Canceled after 21m42s
- roles.driver (unset: the planner's model) and roles.classifier (unset:
  the expert's); the mapping is pinned when an item opens, and the driver
  and the go-ahead reader run on the item's own (L9).
- profiles: in configuration (tools, strengths, weaknesses), used in
  briefings: the driver's instructions say what its helper and expert are
  good at, the helper's brief who reads its handback; never a model name.
- delegate(task) and ask_expert(question) for the driver. A helper works
  on a branch off the milestone's; on a handback its work comes back as
  uncommitted changes, otherwise it is discarded, so harness branches
  never hold commits only the clone has. ask_expert keeps its budget.
- Triggers: failure_limit failed commands in a row (Driver's new "stuck"
  outcome) or the turn cap, mid-milestone, call a helper in once per
  milestone; if it fails too, the item waits on the operator. A
  delegation cut off by a crash is discarded at boot.
- Evaluation capture: delegations and ask_expert as before, and the
  reader's verdicts as classifier pairs.

Tests: help tests (merge-back, discard, needs a milestone, the stuck
trigger and its once-per-milestone limit, the expert and its budget,
pinning, briefings by profile, boot discarding a cut-off delegation),
config tests for roles and profiles, and the walk delegating with a
scripted helper that sometimes fails, with a delegations_settle property.
The walk found helper commits stranded on a merged PR's branch, fixed by
returning a helper's work uncommitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-07 16:33:26 +00:00
dongarandClaude Opus 5.5 064d9bf554 Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
  build_host, push credential, labels (defaults added to every issue),
  secrets by ${VAR} reference with visibility (commands | harness), and
  egress (recorded, not enforced). Every problem in every entry is
  reported at once; an inline secret, a bad visibility, a repeated repo or
  a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
  commands-visible secrets, redacted on return), push credential and
  milestones; intake polls every project's repository; items are scoped
  to their project for every run, publish and PR watch. Issues are named
  project#14 when there is more than one project, and a bare #14 that
  matches two is asked about. Switching projects parks the one worked on
  last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
  posted: the e2e driver kept asking "Is this the right interpretation?",
  which a plain yes can't answer.

Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-06 01:12:57 +00:00
dongarandClaude Opus 5.5 4d9c1d7529 Threads, commands and labels on the new loop (M2.3)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s
- Commands that name their item ("go #14", "abandon #14", "stop #14")
  apply to it wherever they're said; go, approve and abandon alone at top
  level are asked about (L5). stop is never taken as an answer.
- A plain-language reply to a restatement is read by GoAheadReader, one
  model call that agrees only on a clear yes; questions never reach it. The
  agent says "taking that as a go-ahead on rev-N" first, bound to the
  revision on screen at arrival (L3, L5, D1).
- Labels steer (D1): `bug` goes ahead after tuning.loop.bug_grace_s
  (default 600 s) unless the operator replies; `discuss` isn't offered
  restate until the operator has spoken; `bug`, `discuss` and `design` add
  to the driver's instructions. A restatement asks no questions.

Tests: label and command unit tests, the property walk with labelled
issues, plain yeses, named gos and time passing (grace_only_unanswered,
discuss_talks_first), and e2e tests for a bug proceeding after its grace
period and a plain-language go-ahead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-04 16:13:22 +00:00
dongarandClaude Opus 5.5 f84bb70839 The new loop's core: intake, restatement, go-ahead, threads (M2.1, PR B)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m44s
nisse_dev.loop runs alongside control.py, chosen with `loop: new`.
Issues assigned to the agent (by an operator, or filed by one) become work
items, each with a Matrix thread. A driver run is one model with tools
until it calls a waiting tool: restate puts rev-N up for a go-ahead, bound
to the revision on screen when the reply arrived; ask_human waits for an
answer; close_item closes the item and its issue. Before the go-ahead the
driver can look but not change or close anything. One item is active at a
time; replies in an item's thread go to it. On boot, an item cut off
mid-run waits on the operator, and waiting points are put up again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 23:46:42 +00:00
dongarandClaude Opus 5.5 d5cef02e89 Keep credentials out of reprs; count only live ssh-agents in tests
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m43s
A failing CI test printed a test key through SSHKey's default repr. Push
credentials, AgentConfig's tokens and MatrixConfig's token are now
excluded from their reprs, so no failure or log line can print them. In
CI's container killed ssh-agents linger as zombies, so the tests count
only live ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 22:41:01 +00:00
dongarandClaude Opus 5.5 c931ac939b Let pushes go by an HTTPS token or an SSH deploy key
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
The push credential is either a push-only account's token, handed to git
through a credential helper that names environment variables, never
values, or a deploy key in a short-lived ssh-agent. The remote URL
follows the credential. Configured as gitea.push_user and push_token, or
gitea.push_key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 21:59:56 +00:00
dongarandClaude Opus 5.5 761ebdb857 Run every command on the build VM over SSH (M1.3)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 55s
execution.SSHHost.execute is now the only way the agent runs anything
(L13). Commands travel as a script on SSH's stdin, so the secrets handed
to a call are never on a command line, and every value handed out is
redacted from the output. Timeouts kill the remote process group, and
an unreachable host is reported as such, not as a failed command.

Workspace and GitRepo run on the build VM. The clone uses the
repository's SSH URL, so no credential is stored in it (#89); fetches
and pushes get the push key over stdin, in a short-lived ssh-agent,
and PRs are still opened from the agent's host with its API token.
The bench runs on the build host too.

Tests reach "the build VM" through a throwaway local sshd (nisse_dev
.testing); CI installs openssh-server. The live push test now pushes
with the sandbox's deploy key (NISSE_PUSH_KEY).

Fixes #89.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-02 21:39:46 +00:00
dongarandClaude Opus 5.5 3cf4cefacd Rename the delegate and consultant roles to helper and expert
Architecture L9's role names, for the two roles with a direct
counterpart: the delegation role is now 'helper' and the ask_expert role
'expert', in RoleMap, configuration (roles.helper, roles.expert),
recorded events and tests. 'planner' and 'executor' stay distinct for
now; they become 'driver' when the new loop merges them (Development
Plan M2.3).

Configurations must rename their keys: nisse-dev.example.yaml and
tests/integration/nisse-test.yaml are updated here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 23:04:37 +00:00
dongarandClaude Opus 5.5 1ad0b2147d Retire the old build spec, and point every reference at the architecture (M0.1)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/,
the example config, the tuning guide, and the CLAUDE.md and README files
now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old
spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps
it). The plan's old-to-new table goes with it.

Mostly mechanical, with a per-site choice where one old section split:
A4 became L2 where it meant stop/abandon/the loop and L3 where it meant
approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2
became D4 for outbound Gitea and publishing and D5 for review intake;
'A3 step N' became the matching step of L8's boot sequence. Comments
describing behaviour the architecture drops (planning always escalating,
thresholds tuned at an old build step) now describe the current code and
point at the Development Plan.

docs/Architecture.md gains one line the rewrite missed: intake outages
delay but never fail work (old B2), now in L5, where the poller and
Matrix code cite it.

Comments and docs only; no behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 00:17:48 +00:00
dongarandClaude Opus 5.5 fe73112aa2 Use qwen3.8-27b as the example executor
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
It led the executor benchmark (PR #55): 37/40 over eight tasks and 17/20
on the harder four, ahead of claude-haiku-4-5's 15/20. local-fast, the
previous example, scored 0/8 on the harder four. Model selection is on
the dev executor alone for now; the shared-host question (spec Part H)
stays open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:18:50 +00:00
dongarandClaude Opus 5.5 f881a18ca4 Keep runs with different tuning apart in the report, and raise max_turns to 40
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m11s
The report grouped runs by model, serving note and task version, so
runs made at different tuning were silently averaged together. Tuning
is now part of the key, and a tuning column shows how a row's settings
differ from today's defaults; it appears only when some row needs it.

tuning.executor.max_turns goes from 24 to 40. On the harder benchmark
tasks Haiku, the calibration ceiling, hit 24 on both refactor-rename
runs and both long-milestone runs, one of which had finished the work.
The cap guards against a model trying forever; it isn't a budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 22:25:14 +00:00
dongarandClaude Opus 5.5 d49435d4e5 Render one assistant message per turn, and make the request timeout a setting
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m16s
Two setup failures found while calibrating the new benchmark tasks,
both of which would also fail real milestones.

Devstral's chat template (Mistral) rejected any turn in which the model
wrote some text before a tool call: the OpenAI renderer made one message
per event, so the turn became a text message and a tool-call message in
a row, and llama.cpp answered 500. The renderer now folds consecutive
user or assistant messages into one, which is the OpenAI format for a
turn anyway. Checked against the live server: the captured failing
request no longer occurs, over six real runs.

LiteLLMClient gave up on any request after a fixed 120 s. A dense 27B
model writing a whole module in one turn takes longer, so the executor
escalated work that was going well. llm.request_timeout_s now sets it
for both clients, default 600.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 22:15:30 +00:00
dongarandClaude Opus 5.5 494715efa9 Fit every model request to its context window
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 55s
Every turn re-sent the whole transcript with nothing checking it against
the model's window. On a local model that fails silently: Ollama drops
the start of an over-long prompt, which is where the system prompt and
the plan are. Measured on the Jetson's llama3.2, cut at 4096 tokens with
no error.

- The model catalog reads max_input_tokens and max_output_tokens from
  LiteLLM's model_info, and startup refuses a role on a model with no
  declared window
- BudgetedClient wraps both routes. It fits the canonical events before
  rendering: older tool results cut to head and tail, then the oldest
  turns dropped with a note, keeping calls with their results; a request
  that still cannot fit fails with ContextOverflow and is never sent.
  The output ceiling is the smaller of config and the model's limit
- Tokens are estimated from characters, pessimistically, and the
  estimate is corrected from reported prompt sizes, only ever upward
- The executor takes its scope from the log: the latest plan, which is
  the approved milestone plan, goes in its system prompt, and it sees
  only events after it
- Tool output keeps its head and, mostly, its tail, so a test runner's
  failure summary survives truncation
- tuning.context.keep_recent_turns and safety_margin are new knobs
- scripts/probe_context.py checks a declared window against what the
  server does; it confirmed llama3.2 cuts at the declared 4096
- The tuning guide covers the budget, declaring a local model's limits,
  and the probe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 00:26:30 +00:00
dongarandClaude Opus 5.5 15d69799bf Make the config file the only home for settings
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
With no production instance yet, there is no reason to carry two ways
of configuring the agent. Every environment variable that duplicated a
config key is gone; the environment now supplies only the secrets the
file references by ${…}, plus NISSE_CONFIG and NISSE_ENV to find the
files.

- AgentConfig loads from a file (load, from_file) or a parsed tree
  (from_tree, for tests); a missing file is an error naming the example
- The from_env constructors on LiteLLMClient, AnthropicPassthroughClient,
  GiteaClient, MatrixConfig and RoleMap are removed, with the env
  reading for max output tokens; clients take their settings from config
- The live integration tests share a committed agent config,
  tests/integration/nisse-test.yaml, aimed at test resources, and one
  conftest that loads it and builds clients. NISSE_LIVE_PLANNER_MODEL
  becomes its planner role; NISSE_CAPABILITY_MODELS stays as a per-run
  switch
- A missing secret now fails the live tests instead of skipping them
- CI passes only the three tokens
- .env.example is secrets only; spec A5 says config comes from the file

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:25:58 +00:00
dongarandClaude Opus 5.5 df449c7e34 Move configuration to a YAML file, and make the tuning knobs configurable
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
The thresholds Part H says to tune on the real inference hardware were
hardcoded, so every adjustment would have been a PR. They now live in a
`tuning:` section of a YAML config file, which also replaces flat
environment variables as the home for structure (#32).

- The file (NISSE_CONFIG, default ./nisse-dev.yaml) carries structure
  and holds no secrets: credentials are ${VAR} references resolved from
  the environment, and a reference to an unset variable is an error
- The environment still wins: every existing NISSE_* variable overrides
  its key, and the file is optional, so .env and CI work unchanged
- Validation stays fail-fast and reports every problem at once; unknown
  keys are errors, so a misspelt knob fails at startup instead of
  silently doing nothing
- The config file joins the state store and .env in the check that
  refuses to start when they sit inside the workspace clone
- Tuning covers the executor's turn and failure limits, the consult
  budget, the delegation caps, the command timeout and the tool output
  limit, which Workspace now takes as a setting; the defaults are the
  values the agent already ran with
- llm.max_output_tokens reaches both LLM clients from the config

docs/Tuning-Guide.md explains the knobs for someone learning local
model tuning: how weights, quantization and the KV cache spend VRAM,
what each knob does and what goes wrong at either extreme, and an order
to tune in. nisse-dev.example.yaml documents every key and is checked
by a test so it cannot drift.

Closes #32

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 23:14:07 +00:00
dongarandClaude Opus 5 dfa574aeec Refuse to start with the state store or .env inside the clone
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Answering "is the workspace where the agent runs, or what it works
on?" — it is what it works on, and conflating the two is dangerous
rather than merely untidy.

The agent commits its workspace with git add -A and pushes it, so
anything inside the clone reaches the project repository. A .env
placed there would publish the LiteLLM, Matrix and Gitea tokens as a
side effect of the agent working correctly: silently, once, and
probably unnoticed. Startup now refuses both that and a state store
inside the clone, and .env.example documents the intended layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:31:39 +00:00
dongarandClaude Opus 5 2fb25b8195 Add the composition root and a runnable entry point
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Nothing had ever assembled the agent. main.py was still the hello-world
placeholder from the scaffold, and no code outside the test suite
constructed an Orchestrator — which is how two fully built, fully
tested components came to be reachable from nothing: the delegation
harness, and the Gitea comment poller. Unit tests that construct a
component directly cannot catch that; only assembly can.

- config.py validates every variable before anything is built and
  names all of them at once, rather than failing at first use twenty
  minutes into a milestone on the call that mattered. Whitespace-only
  counts as missing, since a token of spaces passes a truthiness check
  and fails at the first request instead
- agent.py builds the whole graph and owns startup and shutdown order:
  reconcile before accepting commands, and on stop, stop watching,
  then stop talking, then close the store last
- The Gitea account is read via whoami rather than configured: it names
  the identity every permission is evaluated against, and proves the
  token works before anything depends on it
- Poller lifecycle is now a state-machine concern: watch the plan issue
  while a gate stands, the PR while it is open, and stop on leaving.
  The orchestrator owns when to watch; an injected factory owns how to
  build one
- The executor reports each turn, because it runs synchronously inside
  the transport callback: without it the agent is silent for a whole
  milestone and a working run looks exactly like a hung one
- main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM

The entry-point tests changed shape: nisse-dev no longer greets, it
refuses to start unconfigured and says everything that is missing. Both
tests now run with a deliberately empty environment and a .env path
that does not exist, because the working directory's .env holds live
credentials and a test must never be one cwd away from starting a real
agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:26:26 +00:00