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

+15 -8
View File
@@ -61,7 +61,7 @@ class FreshRepo:
@contextlib.contextmanager
def fresh_repo(
operator: GiteaClient, run: str, keys: Path, *, agent: str
operator: GiteaClient, run: str, keys: Path, *, agent: str, seed: bool = True
) -> Iterator[FreshRepo]:
"""A repository of its own for one test run, as the operator would make a
project's (D4, D8): seeded with a small Python package, the ``agent``
@@ -88,14 +88,21 @@ def fresh_repo(
"content": base64.b64encode(path.read_bytes()).decode(),
}
for path in sorted(SEED.rglob("*"))
if path.is_file() and path.name != "README.md"
if seed and path.is_file() and path.name != "README.md"
]
operator._request(
"POST",
f"/repos/{full}/contents",
json={"branch": "main", "message": "Seed the test project", "files": files},
)
if files:
operator._request(
"POST",
f"/repos/{full}/contents",
json={
"branch": "main",
"message": "Seed the test project",
"files": files,
},
)
readme = operator._request("GET", f"/repos/{full}/contents/README.md")
# A new project's README says only what it is; a seeded one, the seed's.
text = (SEED / "README.md").read_bytes() if seed else f"# {name}\n".encode()
operator._request(
"PUT",
f"/repos/{full}/contents/README.md",
@@ -103,7 +110,7 @@ def fresh_repo(
"branch": "main",
"message": "README",
"sha": readme["sha"],
"content": base64.b64encode((SEED / "README.md").read_bytes()).decode(),
"content": base64.b64encode(text).decode(),
},
)
operator._request(
+4
View File
@@ -27,6 +27,10 @@ projects:
llm:
base_url: https://litellm.dongar.net
token: ${NISSE_LLM_TOKEN}
# The test key caps each request's worst-case cost ($0.10): input plus
# this much reserved output. The test agent's replies are short, so half
# the default leaves room for ~40k input tokens on Haiku.
max_output_tokens: 8192
# The live tests exercise the gated loop and the routes over a real proxy,
# not local-model competence (measured separately by test_model_capability),
+92 -3
View File
@@ -12,6 +12,7 @@ scripts/e2e.sh after each milestone.
import base64
import contextlib
import os
import re
import time
import uuid
from collections.abc import Iterator
@@ -83,7 +84,11 @@ def running(
monkeypatch.setattr("nisse_dev.loop.build.MatrixTransport", lambda cfg: transport)
agent = None
try:
agent = build_loop_agent(config)
# What the agent reads when told a new project is ready: replaced by
# a test that adds one (D8).
source = [config]
agent = build_loop_agent(config, load=lambda: source[0])
agent.source = source # type: ignore[attr-defined]
# The test drives intake and PR watching itself, not on a timer.
agent.loop._interval = 3600
agent.start()
@@ -145,11 +150,13 @@ class Operator:
"""Intake and PR watching, as the agent's timer would do."""
self.agent.worker.call(self.agent.loop.intake, timeout=DEADLINE_S)
project: str | None = None # whose issues ``issue`` numbers, if not the only
def item(self, issue: int = 0) -> Any:
"""The work item for ``issue``; by default the first one."""
if issue:
store = self.agent.store
return self.agent.worker.call(lambda: item_for_issue(store, issue))
store, project = self.agent.store, self.project
return self.agent.worker.call(lambda: item_for_issue(store, issue, project))
items = self.agent.worker.call(self.agent.store.work_items)
return items[0] if items else None
@@ -545,3 +552,85 @@ def test_a_conversation_becomes_filed_work(
assert f"Closes #{number}" in pr["body"]
finally:
operator.tidy(issues, prs)
def test_a_conversation_becomes_a_new_project(
running: tuple[RunningLoop, InMemoryTransport, str],
) -> None:
"""M2.5: a conversation proposes a new project; the operator provides
what the checklist asks for (here, a repository of its own made the way
fresh_repo makes one, and the entry the agent wrote); "done" has the
agent check it all, open the tracking issue, and start the architecture."""
import yaml
from nisse_dev.config import _parse_projects
agent, transport, run = running
operator = Operator(agent, transport)
as_operator = operator_gitea(agent.config.gitea_url)
name = f"tally-{run}"
def conversation() -> Any:
items = agent.worker.call(agent.store.work_items)
return next(i for i in items if i.scope == "general")
root = transport.receive(
OPERATOR,
f"I'd like a new project, named {name}: a tiny command-line tally "
"counter in Python 3 with pytest, no services, secrets or extra network "
"access. Please propose it as a new project.",
)
operator.settle()
for _ in range(MAX_ANSWERS):
if loop_of(conversation()).wait == GO_AHEAD:
break
transport.receive(
OPERATOR, f"Yes: propose it as a new project named {name}.", thread=root
)
operator.settle()
assert loop_of(conversation()).proposal_kind == "project", operator.last()
transport.receive(OPERATOR, "go", thread=root)
operator.settle()
checklist = operator.last()
entry = re.search(r"```yaml\n(.*?)\n```", checklist, re.DOTALL)
assert entry is not None, checklist
# --- the operator provides it all, as the checklist asks ---
keep = agent.config.state_db.parent
with fresh_repo(
as_operator, f"{run}-new", keep / "keys", agent=agent.account, seed=False
) as repo:
host = agent.config.build_host
filled = (
entry.group(1)
.replace(f"<owner>/{name}", repo.name)
.replace("<the build VM>", host.address)
.replace("<my SSH key for it>", str(host.key))
.replace("<the deploy key's private half>", str(repo.push_key))
)
errors: list[str] = []
[project] = _parse_projects(yaml.safe_load(filled)["projects"], {}, errors)
assert errors == [], errors
# On this run's build host and in its scratch directory.
project = replace(
project, build_host=host, workspace=Path(f"/tmp/nisse-e2e-{run}/{name}")
)
agent.source[0] = replace( # type: ignore[attr-defined]
agent.config, projects=(*agent.config.projects, project)
)
transport.receive(OPERATOR, "done", thread=root)
operator.settle()
became = loop_of(conversation()).became
assert became, f"not set up: {operator.last()}"
number = int(became[0].split("#")[1])
operator.project = name
item = operator.drive_until(MERGE, number)
assert "design" in loop_of(item).labels
pr = loop_of(item).pr
files = [
f["filename"]
for f in agent.gitea._request("GET", f"/repos/{repo.name}/pulls/{pr}/files")
]
assert "ARCHITECTURE.md" in files, files
assert any(f.startswith("docs/plans/") for f in files), files