New projects from conversation (M2.5, PR B)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 4m56s

- 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-06 14:07:37 +00:00
1 parent b3bf6d5a6e
commit dd6196c742
13 files changed
+723 -54

No files matched your search

+174 -5
View File
@@ -36,6 +36,7 @@ from nisse_dev.loop.items import (
CLOSED,
GO_AHEAD,
MERGE,
PROVISION,
READY,
RUNNING,
WAITING,
@@ -51,6 +52,7 @@ from nisse_dev.loop.tools import (
ASK_HUMAN,
CLOSE_ITEM,
FINISH_MILESTONE,
PROPOSE_PROJECT,
PROPOSE_WORK,
REPLY,
RESTATE,
@@ -133,9 +135,10 @@ is the new issue's body and the work's whole brief.
{projects}
Setting up a new project from here isn't available yet: if that's where the
conversation goes, say so, and that the operator can create the repository
and its project entry."""
If it becomes a new project, use propose_project: its name, what it's for,
and the decisions setting it up depends on. Once agreed, you'll be asked for
what it needs set up; when the operator has it ready and it checks out, its
architecture and build plan are the first piece of work."""
# What an issue's labels add to the driver's instructions (D1).
LABELS = {
@@ -204,6 +207,9 @@ class LoopAgent:
reader: GoAheadReader | None = None,
bug_grace_s: float = BUG_GRACE_S,
general_idle_s: float = GENERAL_IDLE_S,
# A new project's entry, once the operator has added it (D1, D8):
# the projects configured now that this agent doesn't know yet.
reload: Callable[[set[str]], Sequence[Project]] | None = None,
clock: Callable[[], float] = time.time,
intake_interval_s: float = INTAKE_INTERVAL_S,
) -> None:
@@ -224,6 +230,7 @@ class LoopAgent:
self._reader = reader
self._grace = bug_grace_s
self._idle = general_idle_s
self._reload = reload
self._clock = clock
self._interval = intake_interval_s
# thread root -> revision on screen there, read on the transport's
@@ -566,6 +573,7 @@ class LoopAgent:
if (
item.scope == GENERAL
and item.position == WAITING
and not loop.new_project # setting one up can take days
and loop.idle_since
and now - loop.idle_since >= self._idle
):
@@ -761,6 +769,8 @@ class LoopAgent:
item = self._store.work_item(item.id) or item
if command == "abandon":
self._close(item, loop, "abandoned", f"abandoned by {msg.sender}")
elif command == "done" and loop.new_project:
self._provisioned(item, loop)
elif command == "stop":
# Stopping a run took effect on arrival; a stop is never an answer.
if not loop.note.startswith("stopped ("):
@@ -850,6 +860,9 @@ class LoopAgent:
def _go(self, item: WorkItem, loop: Loop, by: str) -> None:
self._fact(item, f"go-ahead on rev-{loop.rev} from {by}")
if item.scope == GENERAL and loop.proposal_kind == "project":
self._checklist(item, loop)
return
if item.scope == GENERAL:
self._file_work(item, loop)
return
@@ -949,6 +962,116 @@ class LoopAgent:
"We can keep talking here.",
)
# -- new projects (D1, D8) --------------------------------------------------
def _checklist(self, item: WorkItem, loop: Loop) -> None:
"""An agreed new project: what it needs, for the operator to provide
(later nisse-ops, O6), with its project entry to add."""
name = loop.proposal_project
entry = (
"projects:\n"
f" {name}:\n"
f" repo: <owner>/{name}\n"
" base_branch: main\n"
f" workspace: /root/work/{name}\n"
" build_host:\n"
" address: <the build VM>\n"
" key: <my SSH key for it>\n"
" push_key: <the deploy key's private half>"
)
self._fact(item, f"provisioning checklist posted for {name}")
self._wait(
item,
replace(
loop,
wait=PROVISION,
note=f"{name}'s setup: say done when it's ready",
new_project=name,
idle_since=self._clock(),
),
f"To set up `{name}`, I need:\n"
f"1. A repository for it, e.g. <owner>/{name}, with a README so its "
"default branch exists.\n"
"2. Protection on the default branch: none of my pushes, and an "
"approval to merge.\n"
f"3. Me ({self._account}) as a collaborator with write access.\n"
"4. A build VM, with the toolchain from the decisions above.\n"
"5. A deploy key with write access, its private half where I run.\n"
"6. This entry in my configuration, with the <...> parts filled "
f"in:\n\n```yaml\n{entry}\n```\n\n"
"Say done when it's ready, and I'll check each of them.",
)
def _provisioned(self, item: WorkItem, loop: Loop) -> None:
""" "done": the entry is read again and the new project checked
(D4: verified, not assumed). What fails is said, per item; when all
is well the tracking issue is opened, and the project's item starts
with its architecture."""
name = loop.new_project
try:
added = list(self._reload(set(self._projects))) if self._reload else []
except Exception as exc: # noqa: BLE001 -- said; the operator fixes it
self._transport.post(
f"I couldn't read the new entry: {exc}. Fix it and say done again.",
thread=loop.thread,
)
return
project = next((p for p in added if p.name == name), None)
if project is None:
self._transport.post(
f"I don't see `{name}` in my configuration yet (item 6). Add its "
"entry and say done again.",
thread=loop.thread,
)
return
failures = (
project.milestones.verify_access(self._account)
if project.milestones is not None
else ["the project has no workspace"]
)
if failures:
self._fact(item, f"{name} checked: " + "; ".join(failures))
self._transport.post(
"Not ready yet: " + "; ".join(failures) + ". Say done again when "
"they're fixed.",
thread=loop.thread,
)
return
self._projects[name] = project
self._fact(item, f"{name} set up and checked: {project.repo}")
brief = self._latest_proposal(item)
new_id = f"{name}-c{uuid.uuid4().hex[:6]}"
title = f"Implement the build plan for {name}"
try:
number = self._gitea.create_issue(
project.repo,
title,
f"{brief}\n\nFrom a conversation with the operator, agreed there "
f"as rev-{loop.rev}. First: ARCHITECTURE.md and the build plan."
f"\n\n<!-- nisse-item: {new_id} -->",
)
self._gitea.assign_issue(project.repo, number, self._account)
except GiteaError as exc:
self._transport.post(
f"{name} checks out, but I couldn't open its tracking issue: "
f"{exc}. Say done to try again.",
thread=loop.thread,
)
return
# D1: the architecture is agreed in its PR, as `design` steers.
design = replace(
project, labels=tuple(dict.fromkeys(("design", *project.labels)))
)
self._start_filed(item, design, new_id, number, title, brief)
loop = loop_of(self._store.work_item(item.id) or item)
save(
self._store,
item.id,
WAITING,
replace(loop, new_project="", proposal_kind=""),
cause=f"{name} provisioned",
)
def _latest_proposal(self, item: WorkItem) -> str:
proposals = [
e.event.plan
@@ -1102,15 +1225,30 @@ class LoopAgent:
outcome = self._driver.run(
self._store.transcript(item.id, "driver"),
system=system,
tools=[REPLY, ASK_HUMAN, PROPOSE_WORK],
tools=[REPLY, ASK_HUMAN, PROPOSE_WORK, PROPOSE_PROJECT],
workspace=None,
checks={"propose_work": self._proposal_problem},
checks={
"propose_work": self._proposal_problem,
"propose_project": self._project_problem,
},
)
finally:
self._running = False
self._cancel.clear()
self._settle(item.id, outcome)
def _project_problem(self, args: dict[str, object]) -> str | None:
name = str(args.get("name", "")).strip()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", name) or name == GENERAL:
return "a project's name is lowercase letters, digits and -, not general"
if name in self._projects:
return f"{name} is a project already: propose_work for work on it"
if not str(args.get("purpose", "")).strip():
return "say what the project is for"
return _restatement_problem(
{"text": f"{args.get('purpose', '')}\n{args.get('decisions', '')}"}
)
def _proposal_problem(self, args: dict[str, object]) -> str | None:
project = str(args.get("project", "")).strip()
if project not in self._projects:
@@ -1233,6 +1371,7 @@ class LoopAgent:
None,
)
elif outcome.kind == "wait" and outcome.tool == "propose_work":
loop = replace(loop, proposal_kind="work")
project = self._projects[str(args.get("project", "")).strip()]
title = str(args.get("title", "")).strip()
summary = str(args.get("summary", "")).strip()
@@ -1261,6 +1400,36 @@ class LoopAgent:
),
None,
)
elif outcome.kind == "wait" and outcome.tool == "propose_project":
name = str(args.get("name", "")).strip()
purpose = str(args.get("purpose", "")).strip()
decisions = str(args.get("decisions", "")).strip()
brief = f"{purpose}\n\nDecisions:\n{decisions}" if decisions else purpose
rev = loop.rev + 1
self._store.transcript(item.id, "driver").append(
PlanSubmitted(plan=brief, model=self._roles.get("planner", ""))
)
self._fact(item, f"new project proposed as rev-{rev}: {name}")
self._shown[loop.thread] = rev
self._transport.post(
f"rev-{rev}: a new project, `{name}`. {brief}\n\nSay go and I'll "
"list what it needs set up, or reply with changes.",
thread=loop.thread,
)
self._wait(
item,
replace(
loop,
rev=rev,
wait=GO_AHEAD,
note="",
proposal_project=name,
proposal_title="",
proposal_kind="project",
idle_since=self._clock(),
),
None,
)
elif outcome.kind == "wait" and outcome.tool == "ask_human":
question = str(args.get("question", "")).strip()
self._answer_where_asked(item, loop, question)