A design milestone holds documents only, enforced
Found by the first local e2e run: qwen3.8-27b put a new project's code in the same PR as its ARCHITECTURE.md, with no build plan. D1 says a `design` item agrees its design before any code, and that rested on wording only. - Until a PR of a `design` item has merged, finish_milestone is sent back if the milestone changes anything but ARCHITECTURE.md and docs/, naming the paths to take out. The merge is recorded (design_merged) and code may follow. - The label's instruction and the new project's brief say so plainly: documents first, in a PR of their own; code after it has merged. Tests: a design milestone with code is sent back and only the documents are published, then code after the merge; the walk's model follows the rule. 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:
1 parent
ad2d090143
commit
88c7d2e8c9
6 files changed
+101
-7
No files matched your search
@@ -255,6 +255,13 @@ class GitRepo:
|
|||||||
self._git("commit", "-q", "-m", message)
|
self._git("commit", "-q", "-m", message)
|
||||||
return self.head_sha()
|
return self.head_sha()
|
||||||
|
|
||||||
|
def changed_paths(self, since: str) -> list[str]:
|
||||||
|
"""Every path the work changes relative to ``since``, committed or
|
||||||
|
not, new files included; build artifacts aren't work (#80)."""
|
||||||
|
tracked = self._git("diff", "--name-only", since).splitlines()
|
||||||
|
new = self._git("ls-files", "--others", "--exclude-standard").splitlines()
|
||||||
|
return sorted({p for p in [*tracked, *new] if p})
|
||||||
|
|
||||||
def left_out(self) -> list[str]:
|
def left_out(self) -> list[str]:
|
||||||
"""Changes not in the last commit: build artifacts the harness left
|
"""Changes not in the last commit: build artifacts the harness left
|
||||||
out, to be reported rather than silently dropped (D4)."""
|
out, to be reported rather than silently dropped (D4)."""
|
||||||
|
|||||||
@@ -150,9 +150,11 @@ LABELS = {
|
|||||||
"discuss": "This issue is labelled discuss: talk it through with the "
|
"discuss": "This issue is labelled discuss: talk it through with the "
|
||||||
"operator (ask_human) before restating, and restate only once you agree "
|
"operator (ask_human) before restating, and restate only once you agree "
|
||||||
"on what is wanted.",
|
"on what is wanted.",
|
||||||
"design": "This issue is labelled design: agree the design with the "
|
"design": "This issue is labelled design: the design comes before any "
|
||||||
"operator before any code. Your restatement puts the design to them; "
|
"code. Your first milestone is documents only: ARCHITECTURE.md and a "
|
||||||
"code waits for their go-ahead on it.",
|
"build plan under docs/plans/, in a PR of their own (if you restate, put "
|
||||||
|
"the design to the operator). Code starts in a later milestone, after that "
|
||||||
|
"PR has merged; the harness refuses a design milestone with anything else.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -519,6 +521,9 @@ class LoopAgent:
|
|||||||
if pr.merged:
|
if pr.merged:
|
||||||
self._fact(item, f"PR #{loop.pr} merged")
|
self._fact(item, f"PR #{loop.pr} merged")
|
||||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||||
|
if "design" in loop.labels and not loop.design_merged:
|
||||||
|
self._fact(item, "the design has merged: code may follow")
|
||||||
|
loop = replace(loop, design_merged=True)
|
||||||
elif pr.state != "open":
|
elif pr.state != "open":
|
||||||
self._fact(item, f"PR #{loop.pr} was closed without merging")
|
self._fact(item, f"PR #{loop.pr} was closed without merging")
|
||||||
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
loop, position = replace(loop, pr=0, pr_url="", branch=""), READY
|
||||||
@@ -1047,7 +1052,8 @@ class LoopAgent:
|
|||||||
project.repo,
|
project.repo,
|
||||||
title,
|
title,
|
||||||
f"{brief}\n\nFrom a conversation with the operator, agreed there "
|
f"{brief}\n\nFrom a conversation with the operator, agreed there "
|
||||||
f"as rev-{loop.rev}. First: ARCHITECTURE.md and the build plan."
|
f"as rev-{loop.rev}. First: ARCHITECTURE.md and the build plan, "
|
||||||
|
"in a PR of documents only; code after it has merged."
|
||||||
f"\n\n<!-- nisse-item: {new_id} -->",
|
f"\n\n<!-- nisse-item: {new_id} -->",
|
||||||
)
|
)
|
||||||
self._gitea.assign_issue(project.repo, number, self._account)
|
self._gitea.assign_issue(project.repo, number, self._account)
|
||||||
@@ -1211,6 +1217,32 @@ class LoopAgent:
|
|||||||
)
|
)
|
||||||
self._last_project = here
|
self._last_project = here
|
||||||
|
|
||||||
|
def _design_problem(self, item_id: str) -> str | None:
|
||||||
|
"""D1, D2: a `design` item agrees its design before any code. Until
|
||||||
|
a PR of it has merged, a milestone holds documents only."""
|
||||||
|
item = self._store.work_item(item_id)
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
loop = loop_of(item)
|
||||||
|
milestones = self._at(item).milestones
|
||||||
|
if "design" not in loop.labels or loop.design_merged or milestones is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
changed = milestones.changed()
|
||||||
|
except GitError:
|
||||||
|
return None # publishing reports what's wrong with the workspace
|
||||||
|
code = [
|
||||||
|
p for p in changed if p != "ARCHITECTURE.md" and not p.startswith("docs/")
|
||||||
|
]
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"not published: this milestone is the design, so it holds documents "
|
||||||
|
"only (ARCHITECTURE.md, docs/); code starts in a later milestone, "
|
||||||
|
"after this one's PR has merged. Take these out of it (they can "
|
||||||
|
"wait in the plan): " + ", ".join(code)
|
||||||
|
)
|
||||||
|
|
||||||
def _converse_run(self, item: WorkItem) -> None:
|
def _converse_run(self, item: WorkItem) -> None:
|
||||||
"""A conversation's turn (L1, L5): it talks, and may propose work on
|
"""A conversation's turn (L1, L5): it talks, and may propose work on
|
||||||
a project. No workspace, nothing to run."""
|
a project. No workspace, nothing to run."""
|
||||||
@@ -1329,7 +1361,10 @@ class LoopAgent:
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
handlers={"start_milestone": lambda args: self._start(item.id, args)},
|
handlers={"start_milestone": lambda args: self._start(item.id, args)},
|
||||||
checks={"restate": _restatement_problem},
|
checks={
|
||||||
|
"restate": _restatement_problem,
|
||||||
|
"finish_milestone": lambda args: self._design_problem(item.id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class Loop:
|
|||||||
proposal_title: str = ""
|
proposal_title: str = ""
|
||||||
proposal_kind: str = "" # "work" on a project, or a new "project"
|
proposal_kind: str = "" # "work" on a project, or a new "project"
|
||||||
new_project: str = "" # a new project being provisioned (D1)
|
new_project: str = "" # a new project being provisioned (D1)
|
||||||
|
design_merged: bool = False # a `design` item's design PR has merged (D1, D2)
|
||||||
# Where the operator last spoke (D5: answer where asked): "" for the
|
# Where the operator last spoke (D5: answer where asked): "" for the
|
||||||
# Matrix thread, or the PR it came from, with the inline location and a
|
# Matrix thread, or the PR it came from, with the inline location and a
|
||||||
# little of what they said, for quoting.
|
# little of what they said, for quoting.
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ class Milestones:
|
|||||||
if git.remote_sha(self._remote, parked, credential=self._credential):
|
if git.remote_sha(self._remote, parked, credential=self._credential):
|
||||||
git.delete_remote_branch(self._remote, parked, credential=self._credential)
|
git.delete_remote_branch(self._remote, parked, credential=self._credential)
|
||||||
|
|
||||||
|
def changed(self) -> list[str]:
|
||||||
|
"""What the current milestone changes, from the default branch."""
|
||||||
|
return self._git.changed_paths(f"origin/{self._base}")
|
||||||
|
|
||||||
# -- a new project's checks (D1, D4) -----------------------------------------
|
# -- a new project's checks (D1, D4) -----------------------------------------
|
||||||
|
|
||||||
def verify_access(self, account: str) -> list[str]:
|
def verify_access(self, account: str) -> list[str]:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from test_loop_milestones import (
|
|||||||
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
|
||||||
|
|
||||||
from nisse_dev.canonical import ToolCall, ToolResult
|
from nisse_dev.canonical import ToolCall, ToolResult
|
||||||
|
from nisse_dev.gitea import Issue
|
||||||
from nisse_dev.loop.items import MERGE, WAITING, loop_of
|
from nisse_dev.loop.items import MERGE, WAITING, loop_of
|
||||||
|
|
||||||
|
|
||||||
@@ -174,3 +175,44 @@ def test_an_unsaid_final_closes_the_issue(world: dict[str, Any]) -> None:
|
|||||||
say(transport, world, "go")
|
say(transport, world, "go")
|
||||||
[pr] = forge.prs
|
[pr] = forge.prs
|
||||||
assert "Closes #14" in forge.prs[pr]["body"]
|
assert "Closes #14" in forge.prs[pr]["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_design_milestone_holds_documents_only(world: dict[str, Any]) -> None:
|
||||||
|
"""D1, D2: a `design` item agrees its design before any code; the
|
||||||
|
harness refuses a design milestone with code in it."""
|
||||||
|
forge: Forge = world["forge"]
|
||||||
|
forge.issues[0] = Issue(14, "Add count_words", "count words", "don", ("design",))
|
||||||
|
model = ScriptedModel(
|
||||||
|
RESTATE,
|
||||||
|
[
|
||||||
|
call("start_milestone", name="design"),
|
||||||
|
call("write_file", path="ARCHITECTURE.md", content="# arch\n"),
|
||||||
|
call("write_file", path="docs/plans/words.md", content="# plan\n"),
|
||||||
|
call("write_file", path="words.py", content="W = 1\n"), # too soon
|
||||||
|
call("finish_milestone", summary="design", final="no"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
call("run_command", command="rm words.py"),
|
||||||
|
call("finish_milestone", summary="design", final="no"),
|
||||||
|
],
|
||||||
|
milestone("words.py", final="yes"), # after the design merged
|
||||||
|
)
|
||||||
|
agent, transport = loop_agent(world, model)
|
||||||
|
agent.intake()
|
||||||
|
say(transport, world, "go")
|
||||||
|
[design] = forge.prs
|
||||||
|
assert sorted(forge.files(design)) == ["ARCHITECTURE.md", "docs/plans/words.md"]
|
||||||
|
refused = [
|
||||||
|
e.event.result
|
||||||
|
for e in world["store"].transcript(item(world).id, "driver").read()
|
||||||
|
if isinstance(e.event, ToolResult)
|
||||||
|
and "this milestone is the design" in e.event.result
|
||||||
|
]
|
||||||
|
assert refused and "words.py" in refused[0]
|
||||||
|
|
||||||
|
forge.merge(design)
|
||||||
|
agent.watch()
|
||||||
|
agent._schedule()
|
||||||
|
code = max(forge.prs)
|
||||||
|
assert code != design and forge.files(code) == ["words.py"]
|
||||||
|
assert loop_of(item(world)).design_merged
|
||||||
@@ -63,6 +63,11 @@ class PolicyModel:
|
|||||||
self.n += 1
|
self.n += 1
|
||||||
offered = {t["function"]["name"] for t in tools}
|
offered = {t["function"]["name"] for t in tools}
|
||||||
facts = [getattr(e.event, "text", "") for e in events]
|
facts = [getattr(e.event, "text", "") for e in events]
|
||||||
|
# A `design` item writes documents until its design has merged.
|
||||||
|
design = "labelled design" in (system or "") and not any(
|
||||||
|
"the design has merged" in t for t in facts
|
||||||
|
)
|
||||||
|
path = f"docs/d{self.n}.md" if design else f"f{self.n}.py"
|
||||||
roll = self.rng.random()
|
roll = self.rng.random()
|
||||||
if roll < 0.08:
|
if roll < 0.08:
|
||||||
return [call("ask_human", question=f"question {self.n}?")]
|
return [call("ask_human", question=f"question {self.n}?")]
|
||||||
@@ -98,7 +103,7 @@ class PolicyModel:
|
|||||||
# Half done, then a question: work a switch must park.
|
# Half done, then a question: work a switch must park.
|
||||||
return [
|
return [
|
||||||
call("start_milestone", name=f"m{self.n}"),
|
call("start_milestone", name=f"m{self.n}"),
|
||||||
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"),
|
call("write_file", path=path, content=f"x = {self.n}\n"),
|
||||||
call("ask_human", question=f"halfway {self.n}?"),
|
call("ask_human", question=f"halfway {self.n}?"),
|
||||||
]
|
]
|
||||||
merged = any("merged" in t for t in facts[-6:])
|
merged = any("merged" in t for t in facts[-6:])
|
||||||
@@ -106,7 +111,7 @@ class PolicyModel:
|
|||||||
return [call("close_item", summary=f"done {self.n}")]
|
return [call("close_item", summary=f"done {self.n}")]
|
||||||
return [
|
return [
|
||||||
call("start_milestone", name=f"m{self.n}"),
|
call("start_milestone", name=f"m{self.n}"),
|
||||||
call("write_file", path=f"f{self.n}.py", content=f"x = {self.n}\n"),
|
call("write_file", path=path, content=f"x = {self.n}\n"),
|
||||||
call(
|
call(
|
||||||
"finish_milestone",
|
"finish_milestone",
|
||||||
summary=f"work {self.n}",
|
summary=f"work {self.n}",
|
||||||
|
|||||||
Reference in new issue
Block a user