New projects from conversation (M2.5, PR B)

- propose_project(name, purpose, decisions) in a conversation goes up for
  a go-ahead (checked: a slug, not an existing project, no trailing
  question).
- On go, the provisioning checklist (D1): a repository with a README,
  protection on its default branch, the agent as a collaborator, a build
  VM, a deploy key, and the project entry as YAML with <...> to fill in.
- On "done", the agent reads its configuration again and adds the new
  project live (build_loop_agent(load=...); only additions), then checks
  what was provided (D4, verified, not assumed): permissions and
  protection as it sees them, then on a throwaway branch with an empty
  commit, a branch push must succeed and a push to the default branch and
  merging its own PR must be refused. The check's PR is closed and its
  branch deleted. Failures are said per item; nothing starts.
- Checked: the tracking issue (the distilled decisions, carrying the new
  item's ID), and the project's item, agreed and labelled `design`, whose
  first milestone is ARCHITECTURE.md and the build plan. A conversation
  waiting on provisioning doesn't close as idle.
- The walk replays exactly from its seed (item IDs too), and event IDs
  stay unique across simulated restarts, as a server's are.
- The test config holds output to 8192 tokens, under the test key's
  per-request cost cap.

Tests: new-project unit tests (checklist and its YAML parse as config, done
before the entry, what isn't set up, a checked project's record and first
PR, an existing name), protection enforced by a hook on the fake forge,
the walk proposing projects and saying done, and an e2e new project in a
repository the test operator provisions from the agent's checklist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-07 15:39:00 +00:00
1 parent 9a86ccb2a4
commit ad2d090143
13 files changed
+723 -54

No files matched your search

+63 -32
View File
@@ -2,6 +2,7 @@
LoopAgent in place of the Orchestrator. Chosen with `loop: new` in config."""
import threading
from collections.abc import Callable
from dataclasses import dataclass
from nisse_dev.agent import (
@@ -14,7 +15,8 @@ from nisse_dev.agent import (
remote_url,
)
from nisse_dev.cancel import Cancel
from nisse_dev.config import AgentConfig
from nisse_dev.config import AgentConfig, ConfigError
from nisse_dev.config import Project as ProjectEntry
from nisse_dev.gitea import GiteaClient
from nisse_dev.loop.agent import LoopAgent, Project
from nisse_dev.loop.driver import Driver
@@ -64,7 +66,48 @@ class RunningLoop:
self._stopped.wait()
def build_loop_agent(config: AgentConfig) -> RunningLoop:
def project_runtime(
config: AgentConfig,
entry: ProjectEntry,
store: StateStore,
gitea: GiteaClient,
account: str,
) -> Project:
"""One project's part of the loop: its clone on its build VM, workspace
(with the secrets its commands may see, redacted on return), push
credential and milestones."""
host = build_host(config, entry)
credential = push_credential(config, entry)
remote = remote_url(gitea, entry.repo, credential)
git = prepare_workspace(host, config, account, remote, credential, entry)
return Project(
name=entry.name,
repo=entry.repo,
labels=entry.labels,
workspace=Workspace(
host,
entry.workspace,
timeout_s=config.tuning.tools.command_timeout_s,
max_output_chars=config.tuning.tools.max_output_chars,
secrets=entry.command_secrets(),
),
milestones=Milestones(
store,
git,
gitea,
repo=entry.repo,
remote=remote,
credential=credential,
base=entry.base_branch,
),
)
def build_loop_agent(
config: AgentConfig, load: Callable[[], AgentConfig] | None = None
) -> RunningLoop:
"""``load`` reads the configuration again, for a new project's entry
(D8); by default from the file this one came from."""
oplog = OpLog("nisse-dev")
worker = SerialWorker(
on_error=lambda exc: oplog.emit(
@@ -77,36 +120,10 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
gitea = GiteaClient(config.gitea_url, config.gitea_token)
account = gitea.whoami()
# D8: each project its own build VM, clone, push credential and milestones.
projects = []
for entry in config.projects:
host = build_host(config, entry)
credential = push_credential(config, entry)
remote = remote_url(gitea, entry.repo, credential)
git = prepare_workspace(host, config, account, remote, credential, entry)
projects.append(
Project(
name=entry.name,
repo=entry.repo,
labels=entry.labels,
workspace=Workspace(
host,
entry.workspace,
timeout_s=config.tuning.tools.command_timeout_s,
max_output_chars=config.tuning.tools.max_output_chars,
# D7: the secrets its commands may see, redacted on return.
secrets=entry.command_secrets(),
),
milestones=Milestones(
store,
git,
gitea,
repo=entry.repo,
remote=remote,
credential=credential,
base=entry.base_branch,
),
)
)
projects = [
project_runtime(config, entry, store, gitea, account)
for entry in config.projects
]
normalized, _passthrough = make_llm_clients(config, catalog)
cancel = Cancel()
# The driver is the planner's model for now (M2.6 brings the driver role).
@@ -133,6 +150,11 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
# The driver's model reads plain-language go-aheads until the
# classifier role arrives (M2.6).
reader=GoAheadReader(normalized, config.roles.resolve("planner")),
reload=lambda known: [
project_runtime(config, entry, store, gitea, account)
for entry in (load or _from_file(config))().projects
if entry.name not in known
],
bug_grace_s=config.tuning.loop.bug_grace_s,
general_idle_s=config.tuning.loop.general_idle_s,
)
@@ -146,3 +168,12 @@ def build_loop_agent(config: AgentConfig) -> RunningLoop:
worker=worker,
_stopped=threading.Event(),
)
def _from_file(config: AgentConfig) -> Callable[[], AgentConfig]:
def load() -> AgentConfig:
if config.config_file is None:
raise ConfigError("this agent wasn't started from a config file")
return AgentConfig.from_file(config.config_file)
return load