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

+27 -1
View File
@@ -14,7 +14,7 @@ from nisse_dev.cancel import Cancel
from nisse_dev.canonical import ToolCall
from nisse_dev.execution import SSHHost
from nisse_dev.git import GitRepo
from nisse_dev.gitea import Comment, Issue, PullRequest, ReviewNote
from nisse_dev.gitea import Comment, GiteaError, Issue, PullRequest, ReviewNote
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.driver import Driver
from nisse_dev.loop.items import ANSWER, MERGE, WAITING, loop_of
@@ -124,6 +124,32 @@ class Forge:
def close_pull_request(self, repo: str, index: int) -> None:
self.prs[index]["state"] = "closed"
# what a new project's checks read (D4)
protected = False
def protect(self) -> None:
"""Protection as the server enforces it: pushes to main refused by a
hook; merging needs an approval nobody here gives."""
hook = self.bare / "hooks" / "pre-receive"
hook.write_text(
"#!/bin/sh\nwhile read old new ref; do\n"
' [ "$ref" = refs/heads/main ] && echo "main is protected" && exit 1\n'
"done\nexit 0\n"
)
hook.chmod(0o755)
self.protected = True
def repository(self, repo: str) -> dict[str, Any]:
return {"permissions": {"push": True}}
def branch(self, repo: str, name: str) -> dict[str, Any]:
return {"protected": self.protected, "user_can_push": not self.protected}
def merge_pull_request(self, repo: str, index: int) -> None:
if self.protected:
raise GiteaError("gitea POST merge returned 405: approvals needed")
self.merge(index)
# what the operator does in Gitea
def merge(self, n: int) -> None:
head = f"refs/heads/{self.prs[n]['head']}"